Merge branch 'feature/m4-project-member' - M4 project & member management (repos, service, API, screens, tests)

This commit is contained in:
Ken Yasue
2026-06-25 01:02:20 +02:00
30 changed files with 2230 additions and 14 deletions

View File

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function DELETE(
_request: NextRequest,
{
params,
}: {
params: Promise<{ projectId: string; userId: string }>;
}
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId, userId } = await params;
const service = createProjectService();
try {
service.removeMember(user.id, Number(projectId), Number(userId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService, createUserRepository } from '@/lib/api/services';
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
import type { ProjectMemberRole } from '@/lib/types';
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 service = createProjectService();
try {
const members = service.getMembers(user.id, Number(projectId));
return NextResponse.json({ members });
} 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 email = String(body.email ?? '');
const role = (
typeof body.role === 'string' ? body.role : 'member'
) as ProjectMemberRole;
// メールアドレスからユーザーを解決する
const targetUser = createUserRepository().findByEmail(email);
if (!targetUser) {
return handleApiError(new NotFoundError('User', email));
}
const service = createProjectService();
try {
const member = service.addMember(
user.id,
Number(projectId),
targetUser.id,
role
);
return NextResponse.json({ member }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } 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 service = createProjectService();
try {
const dashboard = service.getDashboard(user.id, Number(projectId));
return NextResponse.json(dashboard);
} catch (error) {
return handleApiError(error);
}
}
export async function PATCH(
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 = createProjectService();
try {
const project = service.updateProject(user.id, Number(projectId), {
name: typeof body.name === 'string' ? body.name : undefined,
description:
typeof body.description === 'string' ? body.description : undefined,
status:
typeof body.status === 'string'
? (body.status as 'active' | 'on_hold' | 'completed' | 'archived')
: undefined,
});
return NextResponse.json({ project });
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { projectId } = await params;
const service = createProjectService();
try {
service.deleteProject(user.id, Number(projectId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

43
app/api/projects/route.ts Normal file
View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const service = createProjectService();
const projects = service.getMyProjects(user.id);
return NextResponse.json({ projects });
}
export async function POST(request: NextRequest) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const service = createProjectService();
try {
const project = service.createProject(user.id, {
name: String(body.name ?? ''),
description:
typeof body.description === 'string' ? body.description : undefined,
});
return NextResponse.json({ project }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -1,5 +1,9 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectCard } from '@/components/project/ProjectCard';
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
export const dynamic = 'force-dynamic';
@ -9,19 +13,32 @@ export default async function DashboardPage() {
redirect('/login');
}
const service = createProjectService();
const projects = service.getMyProjects(user.id);
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<a href="/profile" className="text-sm text-blue-600 hover:underline">
{user.name}
</a>
</div>
<p className="mt-4 text-gray-600">
<CreateProjectForm />
<section>
<h2 className="mb-3 text-sm font-semibold text-gray-700">
</h2>
{projects.length === 0 ? (
<p className="text-sm text-gray-400">
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { AddMemberForm } from '@/components/project/AddMemberForm';
import { RemoveMemberButton } from '@/components/project/RemoveMemberButton';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const ROLE_LABEL = { admin: '管理者', member: 'メンバー', guest: 'ゲスト' };
export default async function ProjectMembersPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const service = createProjectService();
let project: Awaited<ReturnType<typeof service.getProject>>;
let members: Awaited<ReturnType<typeof service.getMembers>>;
let canManage: boolean;
try {
project = service.getProject(user.id, Number(projectId));
members = service.getMembers(user.id, Number(projectId));
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="members" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
{canManage && <AddMemberForm projectId={project.id} />}
<section className="rounded-lg border bg-white shadow-sm">
<ul className="divide-y">
{members.map((member) => (
<li
key={member.id}
className="flex items-center justify-between p-4"
>
<div>
<p className="font-medium text-gray-800">
{member.user.name}
{member.userId === user.id && (
<span className="ml-2 text-xs text-gray-400">
()
</span>
)}
</p>
<p className="text-sm text-gray-500">{member.user.email}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">
{ROLE_LABEL[member.role] ?? member.role}
</span>
{canManage && member.userId !== user.id && (
<RemoveMemberButton
projectId={project.id}
userId={member.userId}
label="削除"
/>
)}
</div>
</li>
))}
</ul>
</section>
</main>
</div>
);
}

View File

@ -0,0 +1,79 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { DashboardWidget } from '@/components/project/DashboardWidget';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
on_hold: 'On Hold',
completed: 'Completed',
archived: 'Archived',
};
export default async function ProjectOverviewPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const service = createProjectService();
let dashboard;
try {
dashboard = service.getDashboard(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const { project, members } = dashboard;
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="overview" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{project.name}</h1>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{STATUS_LABELS[project.status] ?? project.status}
</span>
</div>
{project.description && (
<p className="text-gray-600">{project.description}</p>
)}
<p className="text-sm text-gray-500">: {members.length}</p>
<div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget
title="進行中ToDo"
empty="ToDo機能は後続マイルストーンで実装されます"
/>
<DashboardWidget
title="最新チャット"
empty="チャット機能は後続マイルストーンで実装されます"
/>
<DashboardWidget
title="最新掲示板"
empty="掲示板機能は後続マイルストーンで実装されます"
/>
<DashboardWidget
title="最近のアクティビティ"
empty="アクティビティログは後続マイルストーンで実装されます"
/>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createProjectService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ProjectSettingsForm } from '@/components/project/ProjectSettingsForm';
import { DeleteProjectButton } from '@/components/project/DeleteProjectButton';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function ProjectSettingsPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const service = createProjectService();
let project;
let canManage;
try {
project = service.getProject(user.id, Number(projectId));
canManage = service.getMemberRole(user.id, Number(projectId)) === 'admin';
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="settings" />
<main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<div className="rounded-lg border bg-white p-6 shadow-sm">
<ProjectSettingsForm project={project} canManage={canManage} />
</div>
{canManage && (
<div className="rounded-lg border border-red-200 bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold text-red-700"></h2>
<p className="mt-1 text-sm text-gray-600">
</p>
<DeleteProjectButton projectId={project.id} />
</div>
)}
</main>
</div>
);
}

View File

@ -0,0 +1,37 @@
'use client';
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
export function Header({ user }: { user: PublicUser }) {
const router = useRouter();
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
}
return (
<header className="flex items-center justify-between border-b bg-white px-6 py-3">
<a href="/dashboard" className="font-bold text-gray-800">
</a>
<nav className="flex items-center gap-4 text-sm">
<a href="/dashboard" className="text-gray-600 hover:underline">
</a>
<a href="/profile" className="text-gray-600 hover:underline">
{user.name}
</a>
<button
type="button"
onClick={handleLogout}
className="text-blue-600 hover:underline"
>
</button>
</nav>
</header>
);
}

View File

@ -0,0 +1,45 @@
const NAV_ITEMS = [
{ href: '', label: '概要' },
{ href: '/members', label: 'メンバー' },
{ href: '/settings', label: '設定' },
] as const;
/**
* プロジェクト内サブナビゲーション。
* href は `/projects/{projectId}` を起点とする相対パス。
*/
export function ProjectNav({
projectId,
active,
}: {
projectId: number;
active: 'overview' | 'members' | 'settings';
}) {
const activeMap: Record<string, boolean> = {
overview: active === 'overview',
members: active === 'members',
settings: active === 'settings',
};
return (
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm">
{NAV_ITEMS.map((item) => {
const key = item.href === '' ? 'overview' : item.href.slice(1);
const href = `/projects/${projectId}${item.href}`;
return (
<a
key={item.href}
href={href}
className={
activeMap[key]
? 'font-medium text-blue-600'
: 'text-gray-600 hover:underline'
}
>
{item.label}
</a>
);
})}
</nav>
);
}

View File

@ -0,0 +1,9 @@
/**
* プロジェクトページ共通のサイドバー。
* プロジェクト内ナビゲーションは ProjectNav に委譲する。
*/
export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<aside className="w-48 shrink-0 border-r bg-gray-50 p-4">{children}</aside>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function AddMemberForm({ projectId }: { projectId: number }) {
const router = useRouter();
const [email, setEmail] = useState('');
const [role, setRole] = useState('member');
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}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role }),
});
if (res.ok) {
setEmail('');
router.refresh();
return;
}
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? 'メンバー追加に失敗しました');
setLoading(false);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded border bg-white p-4 sm:flex-row sm:items-end"
>
<div className="flex-1">
<label htmlFor="member-email" className="block text-sm font-medium">
</label>
<input
id="member-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="member-role" className="block text-sm font-medium">
</label>
<select
id="member-role"
value={role}
onChange={(e) => setRole(e.target.value)}
className="mt-1 rounded border px-3 py-2"
>
<option value="member">member</option>
<option value="admin">admin</option>
<option value="guest">guest</option>
</select>
</div>
<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>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export function CreateProjectForm() {
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = 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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: description || undefined }),
});
if (res.ok) {
const data = (await res.json()) as { project: { id: number } };
router.push(`/projects/${data.project.id}`);
return;
}
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? 'プロジェクトの作成に失敗しました');
setLoading(false);
}
return (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row sm:items-end"
>
<div className="flex-1">
<label htmlFor="project-name" className="block text-sm font-medium">
</label>
<input
id="project-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
maxLength={200}
/>
</div>
<div className="flex-1">
<label
htmlFor="project-description"
className="block text-sm font-medium"
>
</label>
<input
id="project-description"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<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>
{error && (
<p className="text-sm text-red-600 sm:full" role="alert">
{error}
</p>
)}
</form>
);
}

View File

@ -0,0 +1,24 @@
/**
* ダッシュボードの各項目を表示するウィジェットコンテナ。
* M4では骨組みのみ。全項目の集計は M13 で完成させる。
*/
export function DashboardWidget({
title,
children,
empty,
}: {
title: string;
children?: React.ReactNode;
empty?: string;
}) {
return (
<section className="rounded-lg border bg-white p-4 shadow-sm">
<h2 className="mb-3 text-sm font-semibold text-gray-700">{title}</h2>
{children ? (
<div className="space-y-2">{children}</div>
) : (
<p className="text-sm text-gray-400">{empty ?? 'データがありません'}</p>
)}
</section>
);
}

View File

@ -0,0 +1,51 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function DeleteProjectButton({ projectId }: { projectId: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onDelete() {
if (
!window.confirm('プロジェクトを削除しますか?この操作は取り消せません。')
) {
return;
}
setBusy(true);
setError(null);
const res = await fetch(`/api/projects/${projectId}`, {
method: 'DELETE',
});
setBusy(false);
if (res.ok) {
router.push('/dashboard');
router.refresh();
} else {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? '削除に失敗しました');
}
}
return (
<div className="mt-3">
<button
type="button"
onClick={onDelete}
disabled={busy}
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{busy ? '削除中...' : 'プロジェクトを削除'}
</button>
{error && (
<p className="mt-2 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { Project } from '@/lib/types';
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
on_hold: 'On Hold',
completed: 'Completed',
archived: 'Archived',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800',
on_hold: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
archived: 'bg-gray-200 text-gray-700',
};
export function ProjectCard({ project }: { project: Project }) {
return (
<a
href={`/projects/${project.id}`}
className="block rounded-lg border bg-white p-4 shadow-sm transition hover:shadow-md"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-800">{project.name}</h3>
<span
className={`rounded px-2 py-0.5 text-xs ${
STATUS_COLORS[project.status] ?? 'bg-gray-100 text-gray-700'
}`}
>
{STATUS_LABELS[project.status] ?? project.status}
</span>
</div>
{project.description && (
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
{project.description}
</p>
)}
</a>
);
}

View File

@ -0,0 +1,120 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import type { Project } from '@/lib/types';
export function ProjectSettingsForm({
project,
canManage,
}: {
project: Project;
canManage: boolean;
}) {
const router = useRouter();
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description ?? '');
const [status, setStatus] = useState(project.status);
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/${project.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
status,
}),
});
setLoading(false);
if (res.ok) {
setSaved(true);
router.refresh();
} else {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(body?.error?.message ?? '更新に失敗しました');
}
}
if (!canManage) {
return (
<p className="text-sm text-gray-500">
</p>
);
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium">
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium">
</label>
<select
id="status"
value={status}
onChange={(e) =>
setStatus(
e.target.value as 'active' | 'on_hold' | 'completed' | 'archived'
)
}
className="mt-1 rounded border px-3 py-2"
>
<option value="active">active</option>
<option value="on_hold">on_hold</option>
<option value="completed">completed</option>
<option value="archived">archived</option>
</select>
</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,41 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function RemoveMemberButton({
projectId,
userId,
label,
disabled = false,
}: {
projectId: number;
userId: number;
label: string;
disabled?: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onRemove() {
setBusy(true);
const res = await fetch(`/api/projects/${projectId}/members/${userId}`, {
method: 'DELETE',
});
setBusy(false);
if (res.ok) {
router.refresh();
}
}
return (
<button
type="button"
onClick={onRemove}
disabled={disabled || busy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
{busy ? '削除中...' : label}
</button>
);
}

24
lib/api/services.ts Normal file
View File

@ -0,0 +1,24 @@
import { getDb } from '@/lib/db/sqlite';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectService } from '@/services/ProjectService';
/**
* Route Handler用に各Repository/Serviceを構築するファクトリ。
* 依存はすべて同じgetDb()接続を共有し、トランザクション境界を機能させる。
*/
export function createProjectService(): ProjectService {
const db = getDb();
return new ProjectService(
new ProjectRepository(db),
new ProjectMemberRepository(db),
new NotificationRepository(db),
db
);
}
export function createUserRepository(): UserRepository {
return new UserRepository(getDb());
}

View File

@ -0,0 +1,66 @@
import { ValidationError } from '@/lib/errors';
import type { ProjectStatus } from '@/lib/types';
const MAX_NAME_LENGTH = 200;
const MAX_DESCRIPTION_LENGTH = 2000;
const VALID_STATUSES: ProjectStatus[] = [
'active',
'on_hold',
'completed',
'archived',
];
export interface ProjectCreateInput {
name: string;
description?: string;
}
export interface ProjectUpdateInput {
name?: string;
description?: string;
status?: ProjectStatus;
}
export function validateProjectCreate(input: ProjectCreateInput): void {
if (!input.name || !input.name.trim()) {
throw new ValidationError('プロジェクト名を入力してください', 'name');
}
if (input.name.length > MAX_NAME_LENGTH) {
throw new ValidationError(
`プロジェクト名は${MAX_NAME_LENGTH}文字以内で入力してください`,
'name'
);
}
if (input.description && input.description.length > MAX_DESCRIPTION_LENGTH) {
throw new ValidationError(
`説明文は${MAX_DESCRIPTION_LENGTH}文字以内で入力してください`,
'description'
);
}
}
export function validateProjectUpdate(input: ProjectUpdateInput): void {
if (input.name !== undefined) {
if (!input.name.trim()) {
throw new ValidationError('プロジェクト名を入力してください', 'name');
}
if (input.name.length > MAX_NAME_LENGTH) {
throw new ValidationError(
`プロジェクト名は${MAX_NAME_LENGTH}文字以内で入力してください`,
'name'
);
}
}
if (
input.description !== undefined &&
input.description.length > MAX_DESCRIPTION_LENGTH
) {
throw new ValidationError(
`説明文は${MAX_DESCRIPTION_LENGTH}文字以内で入力してください`,
'description'
);
}
if (input.status !== undefined && !VALID_STATUSES.includes(input.status)) {
throw new ValidationError('無効なステータスです', 'status');
}
}

View File

@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 60_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,

View File

@ -0,0 +1,45 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { Notification, NotificationType } from '@/lib/types';
export interface CreateNotificationInput {
userId: number;
projectId: number | null;
type: NotificationType;
title: string;
body: string | null;
}
/**
* notificationsテーブルへのデータアクセスを担うRepository。
* M4ではメンバー追加通知のために create のみ実装する。
* 一覧取得・既読化は M5 で拡張する。
*/
export class NotificationRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateNotificationInput): Notification {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at)
VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`,
{
userId: input.userId,
projectId: input.projectId,
type: input.type,
title: input.title,
body: input.body,
createdAt: now,
}
);
return {
id: Number(result.lastInsertRowid),
userId: input.userId,
projectId: input.projectId,
type: input.type,
title: input.title,
body: input.body,
readAt: null,
createdAt: now,
};
}
}

View File

@ -0,0 +1,123 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type {
ProjectMember,
ProjectMemberRole,
User,
UserRole,
UserStatus,
} from '@/lib/types';
interface ProjectMemberRow {
id: number;
project_id: number;
user_id: number;
role: string;
joined_at: string;
}
interface MemberWithUserRow extends ProjectMemberRow {
name: string;
email: string;
avatar_url: string | null;
user_role: string;
user_status: string;
}
export interface ProjectMemberWithUser extends ProjectMember {
user: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl' | 'role' | 'status'>;
}
function mapMember(row: ProjectMemberRow): ProjectMember {
return {
id: row.id,
projectId: row.project_id,
userId: row.user_id,
role: row.role as ProjectMemberRole,
joinedAt: row.joined_at,
};
}
function mapMemberWithUser(row: MemberWithUserRow): ProjectMemberWithUser {
return {
...mapMember(row),
user: {
id: row.user_id,
name: row.name,
email: row.email,
avatarUrl: row.avatar_url,
role: row.user_role as UserRole,
status: row.user_status as UserStatus,
},
};
}
/**
* project_membersテーブルへのデータアクセスを担うRepository。
*/
export class ProjectMemberRepository {
constructor(private readonly db: SqliteDatabase) {}
findByProject(projectId: number): ProjectMemberWithUser[] {
const rows = this.db.query<MemberWithUserRow>(
`SELECT pm.*, u.name, u.email, u.avatar_url, u.role AS user_role, u.status AS user_status
FROM project_members pm
INNER JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = @projectId
ORDER BY pm.id`,
{ projectId }
);
return rows.map(mapMemberWithUser);
}
findByUser(userId: number): ProjectMember[] {
const rows = this.db.query<ProjectMemberRow>(
'SELECT * FROM project_members WHERE user_id = @userId ORDER BY id',
{ userId }
);
return rows.map(mapMember);
}
add(
projectId: number,
userId: number,
role: ProjectMemberRole
): ProjectMember {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO project_members (project_id, user_id, role, joined_at)
VALUES (@projectId, @userId, @role, @joinedAt)`,
{ projectId, userId, role, joinedAt: now }
);
return {
id: Number(result.lastInsertRowid),
projectId,
userId,
role,
joinedAt: now,
};
}
remove(projectId: number, userId: number): boolean {
const result = this.db.execute(
'DELETE FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return result.changes > 0;
}
isMember(projectId: number, userId: number): boolean {
const row = this.db.get<{ id: number }>(
'SELECT id FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return row !== null;
}
getRole(projectId: number, userId: number): ProjectMemberRole | null {
const row = this.db.get<{ role: string }>(
'SELECT role FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return row ? (row.role as ProjectMemberRole) : null;
}
}

View File

@ -0,0 +1,126 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { Project, ProjectStatus } from '@/lib/types';
interface ProjectRow {
id: number;
name: string;
description: string | null;
status: string;
owner_id: number;
created_at: string;
updated_at: string;
}
function mapProject(row: ProjectRow): Project {
return {
id: row.id,
name: row.name,
description: row.description,
status: row.status as ProjectStatus,
ownerId: row.owner_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export interface CreateProjectInput {
name: string;
description?: string;
ownerId: number;
}
export interface UpdateProjectInput {
name?: string;
description?: string | null;
status?: ProjectStatus;
}
/**
* projectsテーブルへのデータアクセスを担うRepository。
*/
export class ProjectRepository {
constructor(private readonly db: SqliteDatabase) {}
findById(id: number): Project | null {
const row = this.db.get<ProjectRow>(
'SELECT * FROM projects WHERE id = @id',
{ id }
);
return row ? mapProject(row) : null;
}
findByOwner(ownerId: number): Project[] {
const rows = this.db.query<ProjectRow>(
'SELECT * FROM projects WHERE owner_id = @ownerId ORDER BY id',
{ ownerId }
);
return rows.map(mapProject);
}
/** ユーザーが参加しているプロジェクト一覧を取得する */
findProjectsByUserId(userId: number): Project[] {
const rows = this.db.query<ProjectRow>(
`SELECT p.* FROM projects p
INNER JOIN project_members pm ON pm.project_id = p.id
WHERE pm.user_id = @userId
ORDER BY p.id`,
{ userId }
);
return rows.map(mapProject);
}
create(input: CreateProjectInput): Project {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
{
name: input.name,
description: input.description ?? null,
status: 'active',
ownerId: input.ownerId,
createdAt: now,
updatedAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) {
throw new Error('Failed to create project');
}
return created;
}
update(id: number, input: UpdateProjectInput): Project | null {
const fields: string[] = ['updated_at = @updatedAt'];
const params: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
id,
};
if (input.name !== undefined) {
fields.push('name = @name');
params.name = input.name;
}
if (input.description !== undefined) {
fields.push('description = @description');
params.description = input.description;
}
if (input.status !== undefined) {
fields.push('status = @status');
params.status = input.status;
}
this.db.execute(
`UPDATE projects SET ${fields.join(', ')} WHERE id = @id`,
params
);
return this.findById(id);
}
delete(id: number): boolean {
const result = this.db.execute('DELETE FROM projects WHERE id = @id', {
id,
});
return result.changes > 0;
}
}

162
services/ProjectService.ts Normal file
View File

@ -0,0 +1,162 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import {
ProjectMemberRepository,
type ProjectMemberWithUser,
} from '@/repositories/ProjectMemberRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import {
validateProjectCreate,
validateProjectUpdate,
type ProjectUpdateInput,
} from '@/lib/validators/projectValidator';
import { NotFoundError, ForbiddenError, ConflictError } from '@/lib/errors';
import type { Project, ProjectMember, ProjectMemberRole } from '@/lib/types';
export interface CreateProjectInput {
name: string;
description?: string;
}
export interface ProjectDashboard {
project: Project;
members: ProjectMemberWithUser[];
}
/**
* プロジェクト・メンバー管理の業務ロジックを担うService。
* 権限チェック(isMember/ロール)・トランザクション境界・メンバー追加通知を扱う。
*/
export class ProjectService {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationRepository: NotificationRepository,
private readonly db: SqliteDatabase
) {}
createProject(actorId: number, input: CreateProjectInput): Project {
validateProjectCreate(input);
return this.db.transaction(() => {
const project = this.projectRepository.create({
name: input.name,
description: input.description,
ownerId: actorId,
});
// プロジェクト作成者を管理者(admin)メンバーとして登録
this.projectMemberRepository.add(project.id, actorId, 'admin');
return project;
});
}
getProject(actorId: number, projectId: number): Project {
return this.requireProjectMember(projectId, actorId);
}
getMyProjects(userId: number): Project[] {
return this.projectRepository.findProjectsByUserId(userId);
}
updateProject(
actorId: number,
projectId: number,
input: ProjectUpdateInput
): Project {
validateProjectUpdate(input);
this.requireProjectAdmin(projectId, actorId);
const updated = this.projectRepository.update(projectId, input);
if (!updated) {
throw new NotFoundError('Project', projectId);
}
return updated;
}
archiveProject(actorId: number, projectId: number): Project {
this.requireProjectAdmin(projectId, actorId);
const updated = this.projectRepository.update(projectId, {
status: 'archived',
});
if (!updated) {
throw new NotFoundError('Project', projectId);
}
return updated;
}
deleteProject(actorId: number, projectId: number): void {
this.requireProjectAdmin(projectId, actorId);
this.projectRepository.delete(projectId);
}
getMembers(actorId: number, projectId: number): ProjectMemberWithUser[] {
this.requireProjectMember(projectId, actorId);
return this.projectMemberRepository.findByProject(projectId);
}
/** 操作者のプロジェクト内ロールを取得する(非参加者・非存在プロジェクトは null */
getMemberRole(actorId: number, projectId: number): ProjectMemberRole | null {
return this.projectMemberRepository.getRole(projectId, actorId);
}
addMember(
actorId: number,
projectId: number,
userId: number,
role: ProjectMemberRole
): ProjectMember {
this.requireProjectAdmin(projectId, actorId);
if (this.projectMemberRepository.isMember(projectId, userId)) {
throw new ConflictError('このユーザーは既にメンバーです');
}
return this.db.transaction(() => {
const member = this.projectMemberRepository.add(projectId, userId, role);
// 追加されたユーザーへ通知(M5のNotificationService連携相当を直接記録)
this.notificationRepository.create({
userId,
projectId,
type: 'project_added',
title: 'プロジェクトに追加されました',
body: `プロジェクトID ${projectId} のメンバーに追加されました`,
});
return member;
});
}
removeMember(actorId: number, projectId: number, userId: number): void {
this.requireProjectMember(projectId, actorId);
const actorRole = this.projectMemberRepository.getRole(projectId, actorId);
// 管理者は誰でも削除可能。一般メンバーは自分自身のみ削除可能。
if (actorId !== userId && actorRole !== 'admin') {
throw new ForbiddenError('他のメンバーの削除には管理者権限が必要です');
}
const removed = this.projectMemberRepository.remove(projectId, userId);
if (!removed) {
throw new NotFoundError('Member', userId);
}
}
getDashboard(actorId: number, projectId: number): ProjectDashboard {
const project = this.requireProjectMember(projectId, actorId);
const members = this.projectMemberRepository.findByProject(projectId);
return { project, members };
}
private requireProjectMember(projectId: number, actorId: number): Project {
const project = this.projectRepository.findById(projectId);
if (!project) {
throw new NotFoundError('Project', projectId);
}
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
return project;
}
private requireProjectAdmin(projectId: number, actorId: number): Project {
const project = this.requireProjectMember(projectId, actorId);
const role = this.projectMemberRepository.getRole(projectId, actorId);
if (role !== 'admin') {
throw new ForbiddenError('プロジェクト管理者権限が必要です');
}
return project;
}
}

View File

@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function registerAndLogin(
page: import('@playwright/test').Page,
email: string
) {
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/);
}
function extractProjectId(url: string): number {
const match = url.match(/\/projects\/(\d+)/);
if (!match) throw new Error(`project id not found in ${url}`);
return Number(match[1]);
}
test.describe('project management', () => {
test('create a project, add and remove a member, then archive', async ({
page,
request,
}) => {
const ownerEmail = unique('owner') + '@example.com';
const memberEmail = unique('member') + '@example.com';
const projectName = unique('Project');
// オーナー登録+ログイン
await registerAndLogin(page, ownerEmail);
// プロジェクト作成(UI)
await page.getByLabel('プロジェクト名').fill(projectName);
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
await expect(
page.getByRole('heading', { name: projectName })
).toBeVisible();
const projectId = extractProjectId(page.url());
// 追加用メンバーを事前登録
await request.post('/api/auth/register', {
data: {
name: 'Member User',
email: memberEmail,
password: 'password123',
},
});
// メンバー追加(オーナーのセッションでAPI呼び出し)
const addRes = await page.request.post(
`/api/projects/${projectId}/members`,
{ data: { email: memberEmail, role: 'member' } }
);
expect(addRes.ok()).toBeTruthy();
const { member } = (await addRes.json()) as {
member: { userId: number };
};
// メンバー一覧画面に反映されているか検証(UI)
await page.goto(`/projects/${projectId}/members`);
await expect(page.getByText('Member User')).toBeVisible();
// メンバー削除(API) → 一覧から消えるか検証(UI)
const delRes = await page.request.delete(
`/api/projects/${projectId}/members/${member.userId}`
);
expect(delRes.ok()).toBeTruthy();
await page.reload();
await expect(page.getByText('Member User')).toHaveCount(0);
// アーカイブ(API) → 概要画面に反映されているか検証(UI)
const archiveRes = await page.request.patch(`/api/projects/${projectId}`, {
data: { status: 'archived' },
});
expect(archiveRes.ok()).toBeTruthy();
await page.goto(`/projects/${projectId}`);
await expect(page.getByText('Archived')).toBeVisible();
});
test('non-member cannot access a project they do not belong to', async ({
browser,
request,
}) => {
const ownerEmail = unique('owner') + '@example.com';
const outsiderEmail = unique('outsider') + '@example.com';
// オーナーがプロジェクトを作成
const ownerContext = await browser.newContext();
const ownerPage = await ownerContext.newPage();
await registerAndLogin(ownerPage, ownerEmail);
await ownerPage.getByLabel('プロジェクト名').fill(unique('Secret'));
await ownerPage.getByRole('button', { name: '新規プロジェクト' }).click();
const projectUrl = ownerPage.url();
// 外部ユーザーは登録のみ(プロジェクト非参加)
await request.post('/api/auth/register', {
data: {
name: 'Outsider',
email: outsiderEmail,
password: 'password123',
},
});
const outsiderContext = await browser.newContext();
const outsiderPage = await outsiderContext.newPage();
await outsiderPage.goto('/login');
await outsiderPage.getByLabel('メールアドレス').fill(outsiderEmail);
await outsiderPage.getByLabel('パスワード').fill('password123');
await outsiderPage.getByRole('button', { name: 'ログイン' }).click();
await expect(outsiderPage).toHaveURL(/\/dashboard/);
// プロジェクト詳細へ直接アクセス → ダッシュボードへリダイレクト
await outsiderPage.goto(projectUrl);
await expect(outsiderPage).toHaveURL(/\/dashboard$/);
await ownerContext.close();
await outsiderContext.close();
});
});

View File

@ -0,0 +1,99 @@
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 { NotificationRepository } from '@/repositories/NotificationRepository';
import { ProjectService } from '@/services/ProjectService';
import { ForbiddenError } from '@/lib/errors';
describe('project member permission (integration)', () => {
let db: SqliteDatabase;
let userRepo: UserRepository;
let service: ProjectService;
let ownerA: number;
let memberB: number;
let outsiderC: number;
beforeEach(() => {
db = createMigratedTestDb();
userRepo = new UserRepository(db);
service = new ProjectService(
new ProjectRepository(db),
new ProjectMemberRepository(db),
new NotificationRepository(db),
db
);
ownerA = userRepo.create({
name: 'A',
email: 'a@example.com',
passwordHash: 'h',
}).id;
memberB = userRepo.create({
name: 'B',
email: 'b@example.com',
passwordHash: 'h',
}).id;
outsiderC = userRepo.create({
name: 'C',
email: 'c@example.com',
passwordHash: 'h',
}).id;
});
afterEach(() => {
db.close();
});
it('non-members cannot access any project data; members can', () => {
const p1 = service.createProject(ownerA, { name: 'P1' });
service.addMember(ownerA, p1.id, memberB, 'member');
// メンバー B はアクセス可能
expect(() => service.getProject(memberB, p1.id)).not.toThrow();
expect(() => service.getDashboard(memberB, p1.id)).not.toThrow();
expect(service.getMembers(memberB, p1.id).length).toBeGreaterThan(0);
// 非メンバー C はすべて拒否
expect(() => service.getProject(outsiderC, p1.id)).toThrow(ForbiddenError);
expect(() => service.getDashboard(outsiderC, p1.id)).toThrow(
ForbiddenError
);
expect(() => service.getMembers(outsiderC, p1.id)).toThrow(ForbiddenError);
});
it('a member of one project cannot access another project', () => {
const p1 = service.createProject(ownerA, { name: 'P1' });
const p2 = service.createProject(ownerA, { name: 'P2' });
service.addMember(ownerA, p1.id, memberB, 'member');
// B は P1 のメンバーだが P2 には非参加
expect(() => service.getProject(memberB, p2.id)).toThrow(ForbiddenError);
});
it('a non-admin member cannot manage members or edit the project', () => {
const p1 = service.createProject(ownerA, { name: 'P1' });
service.addMember(ownerA, p1.id, memberB, 'member');
expect(() =>
service.updateProject(memberB, p1.id, { name: 'hacked' })
).toThrow(ForbiddenError);
expect(() =>
service.addMember(memberB, p1.id, outsiderC, 'member')
).toThrow(ForbiddenError);
expect(() => service.archiveProject(memberB, p1.id)).toThrow(
ForbiddenError
);
});
it('getMyProjects only returns projects the user belongs to', () => {
const p1 = service.createProject(ownerA, { name: 'P1' });
service.createProject(ownerA, { name: 'P2' });
service.addMember(ownerA, p1.id, memberB, 'member');
const projectsForB = service.getMyProjects(memberB);
expect(projectsForB.map((p) => p.id)).toEqual([p1.id]);
});
});

View File

@ -0,0 +1,100 @@
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';
describe('ProjectMemberRepository', () => {
let db: SqliteDatabase;
let userRepo: UserRepository;
let projectRepo: ProjectRepository;
let repo: ProjectMemberRepository;
let ownerId: number;
let projectId: number;
beforeEach(() => {
db = createMigratedTestDb();
userRepo = new UserRepository(db);
projectRepo = new ProjectRepository(db);
repo = new ProjectMemberRepository(db);
ownerId = userRepo.create({
name: 'Owner',
email: 'owner@example.com',
passwordHash: 'h',
}).id;
projectId = projectRepo.create({ name: 'P', ownerId }).id;
});
afterEach(() => {
db.close();
});
describe('add / findByProject', () => {
it('adds a member and lists them with user info', () => {
repo.add(projectId, ownerId, 'admin');
const members = repo.findByProject(projectId);
expect(members).toHaveLength(1);
expect(members[0].userId).toBe(ownerId);
expect(members[0].role).toBe('admin');
expect(members[0].user.email).toBe('owner@example.com');
});
it('rejects adding the same member twice (UNIQUE constraint)', () => {
repo.add(projectId, ownerId, 'admin');
expect(() => repo.add(projectId, ownerId, 'member')).toThrow();
});
});
describe('isMember', () => {
it('returns true for a member and false otherwise', () => {
repo.add(projectId, ownerId, 'admin');
expect(repo.isMember(projectId, ownerId)).toBe(true);
expect(repo.isMember(projectId, 99999)).toBe(false);
});
});
describe('getRole', () => {
it('returns the role for a member', () => {
repo.add(projectId, ownerId, 'admin');
expect(repo.getRole(projectId, ownerId)).toBe('admin');
});
it('returns null for a non-member', () => {
expect(repo.getRole(projectId, 99999)).toBeNull();
});
});
describe('remove', () => {
it('removes a member and returns true', () => {
repo.add(projectId, ownerId, 'admin');
expect(repo.remove(projectId, ownerId)).toBe(true);
expect(repo.isMember(projectId, ownerId)).toBe(false);
});
it('returns false when the member does not exist', () => {
expect(repo.remove(projectId, 99999)).toBe(false);
});
});
describe('findByUser', () => {
it('returns memberships for a user across projects', () => {
const other = userRepo.create({
name: 'Other',
email: 'other@example.com',
passwordHash: 'h',
}).id;
const p2 = projectRepo.create({ name: 'P2', ownerId }).id;
repo.add(projectId, other, 'member');
repo.add(p2, other, 'admin');
const memberships = repo.findByUser(other);
expect(memberships).toHaveLength(2);
});
});
});

View File

@ -0,0 +1,119 @@
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';
describe('ProjectRepository', () => {
let db: SqliteDatabase;
let userRepo: UserRepository;
let repo: ProjectRepository;
let memberRepo: ProjectMemberRepository;
let ownerId: number;
beforeEach(() => {
db = createMigratedTestDb();
userRepo = new UserRepository(db);
repo = new ProjectRepository(db);
memberRepo = new ProjectMemberRepository(db);
ownerId = userRepo.create({
name: 'Owner',
email: 'owner@example.com',
passwordHash: 'h',
}).id;
});
afterEach(() => {
db.close();
});
describe('create', () => {
it('creates a project with active status and returns it', () => {
const project = repo.create({
name: 'Project A',
description: 'desc',
ownerId,
});
expect(project.id).toBeGreaterThan(0);
expect(project.name).toBe('Project A');
expect(project.description).toBe('desc');
expect(project.status).toBe('active');
expect(project.ownerId).toBe(ownerId);
});
});
describe('findById', () => {
it('returns the project when found', () => {
const created = repo.create({ name: 'P', ownerId });
expect(repo.findById(created.id)?.name).toBe('P');
});
it('returns null when not found', () => {
expect(repo.findById(99999)).toBeNull();
});
});
describe('findByOwner', () => {
it('returns projects owned by the user', () => {
repo.create({ name: 'P1', ownerId });
repo.create({ name: 'P2', ownerId });
const owned = repo.findByOwner(ownerId);
expect(owned).toHaveLength(2);
});
});
describe('findProjectsByUserId', () => {
it('returns only projects the user is a member of', () => {
const p1 = repo.create({ name: 'P1', ownerId });
const p2 = repo.create({ name: 'P2', ownerId });
const other = userRepo.create({
name: 'Other',
email: 'other@example.com',
passwordHash: 'h',
}).id;
memberRepo.add(p1.id, other, 'member');
const projects = repo.findProjectsByUserId(other);
expect(projects.map((p) => p.id)).toEqual([p1.id]);
expect(projects.map((p) => p.id)).not.toContain(p2.id);
});
});
describe('update', () => {
it('updates name, description and status', () => {
const created = repo.create({ name: 'P', ownerId });
const updated = repo.update(created.id, {
name: 'P2',
description: 'new desc',
status: 'archived',
});
expect(updated?.name).toBe('P2');
expect(updated?.description).toBe('new desc');
expect(updated?.status).toBe('archived');
});
it('returns null for a non-existent project', () => {
expect(repo.update(99999, { name: 'X' })).toBeNull();
});
});
describe('delete', () => {
it('deletes the project and returns true', () => {
const created = repo.create({ name: 'P', ownerId });
expect(repo.delete(created.id)).toBe(true);
expect(repo.findById(created.id)).toBeNull();
});
it('returns false when the project does not exist', () => {
expect(repo.delete(99999)).toBe(false);
});
});
});

View File

@ -0,0 +1,230 @@
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 { NotificationRepository } from '@/repositories/NotificationRepository';
import { ProjectService } from '@/services/ProjectService';
import { ForbiddenError, NotFoundError, ConflictError } from '@/lib/errors';
function createService(db: SqliteDatabase) {
return new ProjectService(
new ProjectRepository(db),
new ProjectMemberRepository(db),
new NotificationRepository(db),
db
);
}
function createUser(repo: UserRepository, email: string, name = 'User') {
return repo.create({ name, email, passwordHash: 'hash' });
}
describe('ProjectService', () => {
let db: SqliteDatabase;
let userRepo: UserRepository;
let service: ProjectService;
let ownerId: number;
beforeEach(() => {
db = createMigratedTestDb();
userRepo = new UserRepository(db);
service = createService(db);
ownerId = createUser(userRepo, 'owner@example.com', 'Owner').id;
});
afterEach(() => {
db.close();
});
describe('createProject', () => {
it('creates the project and registers the owner as an admin member', () => {
const project = service.createProject(ownerId, { name: 'P1' });
expect(project.name).toBe('P1');
expect(project.ownerId).toBe(ownerId);
const dashboard = service.getDashboard(ownerId, project.id);
expect(dashboard.members).toHaveLength(1);
expect(dashboard.members[0].role).toBe('admin');
expect(service.getMemberRole(ownerId, project.id)).toBe('admin');
});
});
describe('getMyProjects', () => {
it('lists projects the user participates in', () => {
service.createProject(ownerId, { name: 'P1' });
service.createProject(ownerId, { name: 'P2' });
const projects = service.getMyProjects(ownerId);
expect(projects).toHaveLength(2);
});
});
describe('updateProject', () => {
it('allows an admin to update the project', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const updated = service.updateProject(ownerId, project.id, {
name: 'P1-updated',
status: 'on_hold',
});
expect(updated.name).toBe('P1-updated');
expect(updated.status).toBe('on_hold');
});
it('forbids a non-admin member from updating', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const member = createUser(userRepo, 'member@example.com').id;
service.addMember(ownerId, project.id, member, 'member');
expect(() =>
service.updateProject(member, project.id, { name: 'X' })
).toThrow(ForbiddenError);
});
it('forbids a non-member from updating', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const outsider = createUser(userRepo, 'out@example.com').id;
expect(() =>
service.updateProject(outsider, project.id, { name: 'X' })
).toThrow(ForbiddenError);
});
it('throws NotFoundError for a non-existent project', () => {
expect(() =>
service.updateProject(ownerId, 99999, { name: 'X' })
).toThrow(NotFoundError);
});
});
describe('archiveProject', () => {
it('sets the project status to archived', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const archived = service.archiveProject(ownerId, project.id);
expect(archived.status).toBe('archived');
});
});
describe('deleteProject', () => {
it('deletes the project when the actor is an admin', () => {
const project = service.createProject(ownerId, { name: 'P1' });
service.deleteProject(ownerId, project.id);
expect(() => service.getProject(ownerId, project.id)).toThrow(
NotFoundError
);
});
});
describe('addMember', () => {
it('adds a member and creates a project_added notification', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const newMember = createUser(userRepo, 'new@example.com').id;
service.addMember(ownerId, project.id, newMember, 'member');
expect(service.getMemberRole(newMember, project.id)).toBe('member');
const notifications = db.query<{
id: number;
type: string;
user_id: number;
}>(
'SELECT id, type, user_id FROM notifications WHERE user_id = @userId',
{ userId: newMember }
);
expect(notifications).toHaveLength(1);
expect(notifications[0].type).toBe('project_added');
});
it('rejects adding an existing member with ConflictError', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const newMember = createUser(userRepo, 'new@example.com').id;
service.addMember(ownerId, project.id, newMember, 'member');
expect(() =>
service.addMember(ownerId, project.id, newMember, 'member')
).toThrow(ConflictError);
});
it('forbids a non-admin from adding members', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const member = createUser(userRepo, 'member@example.com').id;
service.addMember(ownerId, project.id, member, 'member');
const target = createUser(userRepo, 'target@example.com').id;
expect(() =>
service.addMember(member, project.id, target, 'member')
).toThrow(ForbiddenError);
});
});
describe('removeMember', () => {
it('allows an admin to remove another member', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const member = createUser(userRepo, 'member@example.com').id;
service.addMember(ownerId, project.id, member, 'member');
service.removeMember(ownerId, project.id, member);
expect(service.getMemberRole(member, project.id)).toBeNull();
});
it('allows a member to remove themselves', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const member = createUser(userRepo, 'member@example.com').id;
service.addMember(ownerId, project.id, member, 'member');
service.removeMember(member, project.id, member);
expect(service.getMemberRole(member, project.id)).toBeNull();
});
it('forbids a member from removing another member', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const member1 = createUser(userRepo, 'm1@example.com').id;
const member2 = createUser(userRepo, 'm2@example.com').id;
service.addMember(ownerId, project.id, member1, 'member');
service.addMember(ownerId, project.id, member2, 'member');
expect(() => service.removeMember(member1, project.id, member2)).toThrow(
ForbiddenError
);
});
});
describe('getDashboard / getProject', () => {
it('returns the project and members for a member', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const dashboard = service.getDashboard(ownerId, project.id);
expect(dashboard.project.id).toBe(project.id);
expect(dashboard.members).toHaveLength(1);
});
it('forbids a non-member from accessing the project (isolation)', () => {
const project = service.createProject(ownerId, { name: 'P1' });
const outsider = createUser(userRepo, 'out@example.com').id;
expect(() => service.getProject(outsider, project.id)).toThrow(
ForbiddenError
);
expect(() => service.getDashboard(outsider, project.id)).toThrow(
ForbiddenError
);
expect(() => service.getMembers(outsider, project.id)).toThrow(
ForbiddenError
);
});
it('throws NotFoundError for a non-existent project', () => {
expect(() => service.getProject(ownerId, 99999)).toThrow(NotFoundError);
});
});
});