feat(m4): project & member management with tests and e2e
- ProjectRepository, ProjectMemberRepository, minimal NotificationRepository - ProjectService: create/update/archive/delete project, add/remove member, permission checks (isMember/admin), member-added notification, getDashboard skeleton, getMyProjects, getMemberRole - projectValidator, API services factory - API routes: projects CRUD, members list/add/remove - Screens: dashboard (project list + create form), project overview, members, settings; layout (Header/Sidebar/ProjectNav) and project components - Unit tests (Project/ProjectMember repositories, ProjectService), integration project-member-permission, e2e project-management
This commit is contained in:
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal file
30
app/api/projects/[projectId]/members/[userId]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/projects/[projectId]/members/route.ts
Normal file
69
app/api/projects/[projectId]/members/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/projects/[projectId]/route.ts
Normal file
79
app/api/projects/[projectId]/route.ts
Normal 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
43
app/api/projects/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import { redirect } from 'next/navigation';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@ -9,19 +13,32 @@ export default async function DashboardPage() {
|
|||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const service = createProjectService();
|
||||||
|
const projects = service.getMyProjects(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 p-8">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="mx-auto max-w-3xl">
|
<Header user={toPublicUser(user)} />
|
||||||
<div className="flex items-center justify-between">
|
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
||||||
<a href="/profile" className="text-sm text-blue-600 hover:underline">
|
<CreateProjectForm />
|
||||||
{user.name} さん
|
<section>
|
||||||
</a>
|
<h2 className="mb-3 text-sm font-semibold text-gray-700">
|
||||||
</div>
|
参加プロジェクト
|
||||||
<p className="mt-4 text-gray-600">
|
</h2>
|
||||||
プロジェクト機能は後続マイルストーンで実装されます。
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
参加しているプロジェクトはありません。
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
86
app/projects/[projectId]/members/page.tsx
Normal file
86
app/projects/[projectId]/members/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/projects/[projectId]/page.tsx
Normal file
79
app/projects/[projectId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
app/projects/[projectId]/settings/page.tsx
Normal file
57
app/projects/[projectId]/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/layout/Header.tsx
Normal file
37
components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/layout/ProjectNav.tsx
Normal file
45
components/layout/ProjectNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
components/layout/Sidebar.tsx
Normal file
9
components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
components/project/AddMemberForm.tsx
Normal file
84
components/project/AddMemberForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
components/project/CreateProjectForm.tsx
Normal file
85
components/project/CreateProjectForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
components/project/DashboardWidget.tsx
Normal file
24
components/project/DashboardWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/project/DeleteProjectButton.tsx
Normal file
51
components/project/DeleteProjectButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/project/ProjectCard.tsx
Normal file
40
components/project/ProjectCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
components/project/ProjectSettingsForm.tsx
Normal file
120
components/project/ProjectSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/project/RemoveMemberButton.tsx
Normal file
41
components/project/RemoveMemberButton.tsx
Normal 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
24
lib/api/services.ts
Normal 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());
|
||||||
|
}
|
||||||
66
lib/validators/projectValidator.ts
Normal file
66
lib/validators/projectValidator.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
|
timeout: 60_000,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|||||||
45
repositories/NotificationRepository.ts
Normal file
45
repositories/NotificationRepository.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
repositories/ProjectMemberRepository.ts
Normal file
123
repositories/ProjectMemberRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
126
repositories/ProjectRepository.ts
Normal file
126
repositories/ProjectRepository.ts
Normal 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
162
services/ProjectService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
tests/e2e/project-management.spec.ts
Normal file
125
tests/e2e/project-management.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
tests/integration/project-member-permission.test.ts
Normal file
99
tests/integration/project-member-permission.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/unit/repositories/ProjectMemberRepository.test.ts
Normal file
100
tests/unit/repositories/ProjectMemberRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
tests/unit/repositories/ProjectRepository.test.ts
Normal file
119
tests/unit/repositories/ProjectRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
230
tests/unit/services/ProjectService.test.ts
Normal file
230
tests/unit/services/ProjectService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user