feat(m11): calendar + milestones with progress calc, tests, e2e
- CalendarRepository (event CRUD, range query, soft-delete, project isolation) + MilestoneRepository (CRUD, findToDosByMilestone) + ScheduleService (aggregated getCalendarEvents = custom events+milestones+todos in range, milestone CRUD, milestone_updated activity, progress auto-calc 0-100) - APIs: calendar events GET/POST/PATCH/DELETE, milestones GET/POST/PATCH + progress - Screens: calendar list view + CalendarEventForm, milestones list with progress bars + MilestoneForm, ProjectNav links - Unit tests (CalendarRepository, MilestoneRepository, ScheduleService incl. progress calc 0/33/100) + e2e calendar
This commit is contained in:
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { eventId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const event = service.updateEvent(user.id, Number(eventId), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||||
|
endAt:
|
||||||
|
typeof body.endAt === 'string'
|
||||||
|
? body.endAt
|
||||||
|
: body.endAt === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ event });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { eventId } = await params;
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
service.deleteEvent(user.id, Number(eventId));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
56
app/api/projects/[projectId]/calendar/events/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } 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 from = request.nextUrl.searchParams.get('from') ?? '';
|
||||||
|
const to = request.nextUrl.searchParams.get('to') ?? '';
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json(
|
||||||
|
service.getCalendarEvents(user.id, Number(projectId), { from, to })
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const event = service.createEvent(user.id, {
|
||||||
|
projectId: Number(projectId),
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
type: (typeof body.type === 'string' ? body.type : 'custom') as never,
|
||||||
|
startAt: String(body.startAt ?? ''),
|
||||||
|
endAt: typeof body.endAt === 'string' ? body.endAt : null,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ event }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
60
app/api/projects/[projectId]/milestones/[id]/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } from '@/lib/api/services';
|
||||||
|
import { UnauthorizedError } from '@/lib/errors';
|
||||||
|
import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { id } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const milestone = service.updateMilestone(user.id, Number(id), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
dueDate:
|
||||||
|
typeof body.dueDate === 'string'
|
||||||
|
? body.dueDate
|
||||||
|
: body.dueDate === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
status:
|
||||||
|
typeof body.status === 'string'
|
||||||
|
? (body.status as 'open' | 'closed')
|
||||||
|
: undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ milestone });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { id } = await params;
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
progress: service.getMilestoneProgress(user.id, Number(id)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
51
app/api/projects/[projectId]/milestones/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createScheduleService } 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 = createScheduleService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
milestones: service.getMilestones(user.id, Number(projectId)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createScheduleService();
|
||||||
|
try {
|
||||||
|
const milestone = service.createMilestone(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ milestone }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/projects/[projectId]/calendar/page.tsx
Normal file
95
app/projects/[projectId]/calendar/page.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createScheduleService,
|
||||||
|
createProjectService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { CalendarEventForm } from '@/components/calendar/CalendarEventForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const SOURCE_COLORS: Record<string, string> = {
|
||||||
|
event: 'bg-blue-100 text-blue-700',
|
||||||
|
milestone: 'bg-purple-100 text-purple-700',
|
||||||
|
todo: 'bg-yellow-100 text-yellow-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CalendarPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
searchParams: Promise<{ from?: string; to?: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
const { from, to } = await searchParams;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// デフォルトは当月
|
||||||
|
const now = new Date();
|
||||||
|
const defaultFrom = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
const rangeFrom = from ?? defaultFrom;
|
||||||
|
const rangeTo = to ?? defaultFrom.replace(/-01$/, '-28');
|
||||||
|
|
||||||
|
const scheduleService = createScheduleService();
|
||||||
|
const events = scheduleService.getCalendarEvents(user.id, project.id, {
|
||||||
|
from: rangeFrom,
|
||||||
|
to: rangeTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="calendar" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">カレンダー</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
期間: {rangeFrom} 〜 {rangeTo}
|
||||||
|
</p>
|
||||||
|
<CalendarEventForm projectId={project.id} defaultDate={defaultFrom} />
|
||||||
|
<section className="space-y-2">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
期間内のイベントはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
events.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.key}
|
||||||
|
className="flex items-center justify-between rounded border bg-white p-3 shadow-sm"
|
||||||
|
data-testid={`calendar-event-${e.key}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-800">{e.title}</p>
|
||||||
|
<p className="text-xs text-gray-400">{e.startAt}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs ${
|
||||||
|
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{e.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
app/projects/[projectId]/milestones/page.tsx
Normal file
93
app/projects/[projectId]/milestones/page.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import {
|
||||||
|
createScheduleService,
|
||||||
|
createProjectService,
|
||||||
|
} from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MilestoneForm } from '@/components/calendar/MilestoneForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function progressColor(progress: number): string {
|
||||||
|
if (progress >= 67) return 'bg-green-500';
|
||||||
|
if (progress >= 34) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MilestonesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const projectService = createProjectService();
|
||||||
|
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||||
|
try {
|
||||||
|
project = projectService.getProject(user.id, Number(projectId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleService = createScheduleService();
|
||||||
|
const milestones = scheduleService.getMilestones(user.id, project.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="milestones" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">マイルストーン</h1>
|
||||||
|
<MilestoneForm projectId={project.id} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{milestones.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
マイルストーンはありません。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
milestones.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||||
|
data-testid={`milestone-${m.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-800">{m.title}</h3>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{m.status}
|
||||||
|
{m.dueDate ? ` / 期限: ${m.dueDate}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{m.description && (
|
||||||
|
<p className="mt-1 text-sm text-gray-600">{m.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>進捗</span>
|
||||||
|
<span data-testid={`milestone-progress-${m.id}`}>
|
||||||
|
{m.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 w-full rounded bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded ${progressColor(m.progress)}`}
|
||||||
|
style={{ width: `${m.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/calendar/CalendarEventForm.tsx
Normal file
78
components/calendar/CalendarEventForm.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function CalendarEventForm({
|
||||||
|
projectId,
|
||||||
|
defaultDate,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
defaultDate: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startAt, setStartAt] = useState(`${defaultDate}T10:00:00`);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/calendar/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, type: 'custom', startAt }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 className="block text-sm font-medium">イベント名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">開始日時</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(e.target.value)}
|
||||||
|
className="mt-1 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" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/calendar/MilestoneForm.tsx
Normal file
73
components/calendar/MilestoneForm.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function MilestoneForm({ projectId }: { projectId: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/milestones`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, dueDate: dueDate || null }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
setDueDate('');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 className="block text-sm font-medium">マイルストーン名</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">期限</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="mt-1 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" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/chat', label: 'チャット' },
|
{ href: '/chat', label: 'チャット' },
|
||||||
{ href: '/todos', label: 'ToDo' },
|
{ href: '/todos', label: 'ToDo' },
|
||||||
{ href: '/files', label: 'ファイル' },
|
{ href: '/files', label: 'ファイル' },
|
||||||
|
{ href: '/calendar', label: 'カレンダー' },
|
||||||
|
{ href: '/milestones', label: 'マイルストーン' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
{ href: '/activity', label: 'アクティビティ' },
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
@ -26,6 +28,8 @@ export function ProjectNav({
|
|||||||
| 'chat'
|
| 'chat'
|
||||||
| 'todos'
|
| 'todos'
|
||||||
| 'files'
|
| 'files'
|
||||||
|
| 'calendar'
|
||||||
|
| 'milestones'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'settings';
|
| 'settings';
|
||||||
@ -37,6 +41,8 @@ export function ProjectNav({
|
|||||||
chat: active === 'chat',
|
chat: active === 'chat',
|
||||||
todos: active === 'todos',
|
todos: active === 'todos',
|
||||||
files: active === 'files',
|
files: active === 'files',
|
||||||
|
calendar: active === 'calendar',
|
||||||
|
milestones: active === 'milestones',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
activity: active === 'activity',
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
|
|||||||
@ -18,6 +18,9 @@ import { TodoService } from '@/services/TodoService';
|
|||||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
import { FileStorageService } from '@/services/FileStorageService';
|
import { FileStorageService } from '@/services/FileStorageService';
|
||||||
import { FileRepository } from '@/repositories/FileRepository';
|
import { FileRepository } from '@/repositories/FileRepository';
|
||||||
|
import { ScheduleService } from '@/services/ScheduleService';
|
||||||
|
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
|
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||||
@ -96,6 +99,17 @@ export function createFileStorageService(): FileStorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createScheduleService(): ScheduleService {
|
||||||
|
const db = getDb();
|
||||||
|
return new ScheduleService(
|
||||||
|
new CalendarRepository(db),
|
||||||
|
new MilestoneRepository(db),
|
||||||
|
new TodoRepository(db),
|
||||||
|
new ProjectMemberRepository(db),
|
||||||
|
new ActivityLogService(new ActivityLogRepository(db))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createUserRepository(): UserRepository {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
return new UserRepository(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
142
repositories/CalendarRepository.ts
Normal file
142
repositories/CalendarRepository.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
import type { CalendarEvent, CalendarEventType } from '@/lib/types';
|
||||||
|
|
||||||
|
interface CalendarEventRow {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: string;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string | null;
|
||||||
|
created_by_id: number;
|
||||||
|
related_todo_id: number | null;
|
||||||
|
related_milestone_id: number | null;
|
||||||
|
related_meeting_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEvent(row: CalendarEventRow): CalendarEvent {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
type: row.type as CalendarEventType,
|
||||||
|
startAt: row.start_at,
|
||||||
|
endAt: row.end_at,
|
||||||
|
createdById: row.created_by_id,
|
||||||
|
relatedTodoId: row.related_todo_id,
|
||||||
|
relatedMilestoneId: row.related_milestone_id,
|
||||||
|
relatedMeetingId: row.related_meeting_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEventInput {
|
||||||
|
projectId: number;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: CalendarEventType;
|
||||||
|
startAt: string;
|
||||||
|
endAt?: string | null;
|
||||||
|
createdById: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarRepository {
|
||||||
|
constructor(private readonly db: SqliteDatabase) {}
|
||||||
|
|
||||||
|
findByProjectInRange(
|
||||||
|
projectId: number,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): CalendarEvent[] {
|
||||||
|
const rows = this.db.query<CalendarEventRow>(
|
||||||
|
`SELECT * FROM calendar_events
|
||||||
|
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||||
|
AND start_at >= @from AND start_at <= @to
|
||||||
|
ORDER BY start_at ASC, id ASC`,
|
||||||
|
{ projectId, from, to }
|
||||||
|
);
|
||||||
|
return rows.map(mapEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
findEventById(id: number): CalendarEvent | null {
|
||||||
|
const row = this.db.get<CalendarEventRow>(
|
||||||
|
'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ id }
|
||||||
|
);
|
||||||
|
return row ? mapEvent(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateEventInput): CalendarEvent {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = this.db.execute(
|
||||||
|
`INSERT INTO calendar_events (project_id, title, description, type, start_at, end_at, created_by_id, related_todo_id, related_milestone_id, related_meeting_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @type, @startAt, @endAt, @createdById, NULL, NULL, NULL, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId: input.projectId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
type: input.type,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt ?? null,
|
||||||
|
createdById: input.createdById,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const created = this.findEventById(Number(result.lastInsertRowid));
|
||||||
|
if (!created) throw new Error('Failed to create calendar event');
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number,
|
||||||
|
input: {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string | null;
|
||||||
|
}
|
||||||
|
): CalendarEvent | null {
|
||||||
|
const fields: string[] = ['updated_at = @updatedAt'];
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
if (input.title !== undefined) {
|
||||||
|
fields.push('title = @title');
|
||||||
|
params.title = input.title;
|
||||||
|
}
|
||||||
|
if (input.description !== undefined) {
|
||||||
|
fields.push('description = @description');
|
||||||
|
params.description = input.description;
|
||||||
|
}
|
||||||
|
if (input.startAt !== undefined) {
|
||||||
|
fields.push('start_at = @startAt');
|
||||||
|
params.startAt = input.startAt;
|
||||||
|
}
|
||||||
|
if (input.endAt !== undefined) {
|
||||||
|
fields.push('end_at = @endAt');
|
||||||
|
params.endAt = input.endAt;
|
||||||
|
}
|
||||||
|
this.db.execute(
|
||||||
|
`UPDATE calendar_events SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return this.findEventById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): boolean {
|
||||||
|
const result = this.db.execute(
|
||||||
|
'UPDATE calendar_events SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ now: new Date().toISOString(), id }
|
||||||
|
);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
repositories/MilestoneRepository.ts
Normal file
168
repositories/MilestoneRepository.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types';
|
||||||
|
|
||||||
|
interface MilestoneRow {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
due_date: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TodoItemRow {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
column_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
assignee_id: number | null;
|
||||||
|
creator_id: number;
|
||||||
|
priority: string;
|
||||||
|
start_date: string | null;
|
||||||
|
due_date: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
order_index: number;
|
||||||
|
milestone_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMilestone(row: MilestoneRow): Milestone {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
dueDate: row.due_date,
|
||||||
|
status: row.status as MilestoneStatus,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTodo(row: TodoItemRow): TodoItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
columnId: row.column_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
assigneeId: row.assignee_id,
|
||||||
|
creatorId: row.creator_id,
|
||||||
|
priority: row.priority as never,
|
||||||
|
startDate: row.start_date,
|
||||||
|
dueDate: row.due_date,
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
orderIndex: row.order_index,
|
||||||
|
milestoneId: row.milestone_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMilestoneInput {
|
||||||
|
projectId: number;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MilestoneRepository {
|
||||||
|
constructor(private readonly db: SqliteDatabase) {}
|
||||||
|
|
||||||
|
findByProject(projectId: number): Milestone[] {
|
||||||
|
const rows = this.db.query<MilestoneRow>(
|
||||||
|
'SELECT * FROM milestones WHERE project_id = @projectId AND deleted_at IS NULL ORDER BY due_date ASC, id ASC',
|
||||||
|
{ projectId }
|
||||||
|
);
|
||||||
|
return rows.map(mapMilestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: number): Milestone | null {
|
||||||
|
const row = this.db.get<MilestoneRow>(
|
||||||
|
'SELECT * FROM milestones WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ id }
|
||||||
|
);
|
||||||
|
return row ? mapMilestone(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateMilestoneInput): Milestone {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = this.db.execute(
|
||||||
|
`INSERT INTO milestones (project_id, title, description, due_date, status, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @dueDate, 'open', @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId: input.projectId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
dueDate: input.dueDate ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const created = this.findById(Number(result.lastInsertRowid));
|
||||||
|
if (!created) throw new Error('Failed to create milestone');
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: number,
|
||||||
|
input: {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
status?: MilestoneStatus;
|
||||||
|
}
|
||||||
|
): Milestone | null {
|
||||||
|
const fields: string[] = ['updated_at = @updatedAt'];
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
if (input.title !== undefined) {
|
||||||
|
fields.push('title = @title');
|
||||||
|
params.title = input.title;
|
||||||
|
}
|
||||||
|
if (input.description !== undefined) {
|
||||||
|
fields.push('description = @description');
|
||||||
|
params.description = input.description;
|
||||||
|
}
|
||||||
|
if (input.dueDate !== undefined) {
|
||||||
|
fields.push('due_date = @dueDate');
|
||||||
|
params.dueDate = input.dueDate;
|
||||||
|
}
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
fields.push('status = @status');
|
||||||
|
params.status = input.status;
|
||||||
|
}
|
||||||
|
this.db.execute(
|
||||||
|
`UPDATE milestones SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): boolean {
|
||||||
|
const result = this.db.execute(
|
||||||
|
'UPDATE milestones SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ now: new Date().toISOString(), id }
|
||||||
|
);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** マイルストーンに紐づくToDo一覧(論理削除除外) */
|
||||||
|
findToDosByMilestone(milestoneId: number): TodoItem[] {
|
||||||
|
const rows = this.db.query<TodoItemRow>(
|
||||||
|
'SELECT * FROM todo_items WHERE milestone_id = @milestoneId AND deleted_at IS NULL',
|
||||||
|
{ milestoneId }
|
||||||
|
);
|
||||||
|
return rows.map(mapTodo);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
services/ScheduleService.ts
Normal file
247
services/ScheduleService.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import {
|
||||||
|
CalendarRepository,
|
||||||
|
type CreateEventInput,
|
||||||
|
} from '@/repositories/CalendarRepository';
|
||||||
|
import {
|
||||||
|
MilestoneRepository,
|
||||||
|
type CreateMilestoneInput,
|
||||||
|
} from '@/repositories/MilestoneRepository';
|
||||||
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||||
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import type {
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarEventType,
|
||||||
|
Milestone,
|
||||||
|
MilestoneStatus,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
/** カレンダー集約ビュー(複数ソースを統一表示) */
|
||||||
|
export interface CalendarEventView {
|
||||||
|
key: string;
|
||||||
|
projectId: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: CalendarEventType;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string | null;
|
||||||
|
source: 'event' | 'milestone' | 'todo';
|
||||||
|
refId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneWithProgress extends Milestone {
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMilestoneInput {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
status?: MilestoneStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* カレンダーイベント集約とマイルストーン管理を担うService。
|
||||||
|
* カレンダーイベントの集計(カスタムイベント+マイルストーン+ToDo)と
|
||||||
|
* マイルストーン進捗率自動計算を行う。
|
||||||
|
*/
|
||||||
|
export class ScheduleService {
|
||||||
|
constructor(
|
||||||
|
private readonly calendarRepository: CalendarRepository,
|
||||||
|
private readonly milestoneRepository: MilestoneRepository,
|
||||||
|
private readonly todoRepository: TodoRepository,
|
||||||
|
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||||
|
private readonly activityLogService: ActivityLogService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getCalendarEvents(
|
||||||
|
actorId: number,
|
||||||
|
projectId: number,
|
||||||
|
range: { from: string; to: string }
|
||||||
|
): CalendarEventView[] {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
const views: CalendarEventView[] = [];
|
||||||
|
|
||||||
|
for (const e of this.calendarRepository.findByProjectInRange(
|
||||||
|
projectId,
|
||||||
|
range.from,
|
||||||
|
range.to
|
||||||
|
)) {
|
||||||
|
views.push({
|
||||||
|
key: `event-${e.id}`,
|
||||||
|
projectId,
|
||||||
|
title: e.title,
|
||||||
|
description: e.description,
|
||||||
|
type: e.type,
|
||||||
|
startAt: e.startAt,
|
||||||
|
endAt: e.endAt,
|
||||||
|
source: 'event',
|
||||||
|
refId: e.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const m of this.milestoneRepository.findByProject(projectId)) {
|
||||||
|
if (!m.dueDate) continue;
|
||||||
|
if (m.dueDate >= range.from && m.dueDate <= range.to) {
|
||||||
|
views.push({
|
||||||
|
key: `milestone-${m.id}`,
|
||||||
|
projectId,
|
||||||
|
title: `マイルストーン: ${m.title}`,
|
||||||
|
description: m.description,
|
||||||
|
type: 'milestone',
|
||||||
|
startAt: m.dueDate,
|
||||||
|
endAt: m.dueDate,
|
||||||
|
source: 'milestone',
|
||||||
|
refId: m.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of this.todoRepository.findItemsByProject(projectId)) {
|
||||||
|
const start = t.dueDate ?? t.startDate;
|
||||||
|
if (!start) continue;
|
||||||
|
if (start >= range.from && start <= range.to) {
|
||||||
|
views.push({
|
||||||
|
key: `todo-${t.id}`,
|
||||||
|
projectId,
|
||||||
|
title: `ToDo: ${t.title}`,
|
||||||
|
description: t.description,
|
||||||
|
type: 'todo',
|
||||||
|
startAt: start,
|
||||||
|
endAt: t.dueDate ?? null,
|
||||||
|
source: 'todo',
|
||||||
|
refId: t.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views.sort((a, b) =>
|
||||||
|
a.startAt < b.startAt ? -1 : a.startAt > b.startAt ? 1 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- calendar events -----
|
||||||
|
createEvent(
|
||||||
|
actorId: number,
|
||||||
|
input: Omit<CreateEventInput, 'projectId' | 'createdById'> & {
|
||||||
|
projectId: number;
|
||||||
|
}
|
||||||
|
): CalendarEvent {
|
||||||
|
this.requireMember(input.projectId, actorId);
|
||||||
|
if (!input.title.trim()) {
|
||||||
|
throw new ValidationError('タイトルを入力してください', 'title');
|
||||||
|
}
|
||||||
|
return this.calendarRepository.create({
|
||||||
|
projectId: input.projectId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
type: input.type,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt ?? null,
|
||||||
|
createdById: actorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvent(
|
||||||
|
actorId: number,
|
||||||
|
eventId: number,
|
||||||
|
input: {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string | null;
|
||||||
|
}
|
||||||
|
): CalendarEvent {
|
||||||
|
const event = this.calendarRepository.findEventById(eventId);
|
||||||
|
if (!event) throw new NotFoundError('CalendarEvent', eventId);
|
||||||
|
this.requireMember(event.projectId, actorId);
|
||||||
|
const updated = this.calendarRepository.update(eventId, input);
|
||||||
|
if (!updated) throw new NotFoundError('CalendarEvent', eventId);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvent(actorId: number, eventId: number): void {
|
||||||
|
const event = this.calendarRepository.findEventById(eventId);
|
||||||
|
if (!event) throw new NotFoundError('CalendarEvent', eventId);
|
||||||
|
this.requireMember(event.projectId, actorId);
|
||||||
|
const role = this.projectMemberRepository.getRole(event.projectId, actorId);
|
||||||
|
if (event.createdById !== actorId && role !== 'admin') {
|
||||||
|
throw new ForbiddenError('作成者または管理者のみ削除できます');
|
||||||
|
}
|
||||||
|
this.calendarRepository.delete(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- milestones -----
|
||||||
|
getMilestones(actorId: number, projectId: number): MilestoneWithProgress[] {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
return this.milestoneRepository.findByProject(projectId).map((m) => ({
|
||||||
|
...m,
|
||||||
|
progress: this.calcMilestoneProgress(m.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
createMilestone(
|
||||||
|
actorId: number,
|
||||||
|
projectId: number,
|
||||||
|
input: Omit<CreateMilestoneInput, 'projectId'>
|
||||||
|
): Milestone {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
if (!input.title.trim()) {
|
||||||
|
throw new ValidationError('タイトルを入力してください', 'title');
|
||||||
|
}
|
||||||
|
return this.milestoneRepository.create({ ...input, projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMilestone(
|
||||||
|
actorId: number,
|
||||||
|
milestoneId: number,
|
||||||
|
input: UpdateMilestoneInput
|
||||||
|
): Milestone {
|
||||||
|
const milestone = this.milestoneRepository.findById(milestoneId);
|
||||||
|
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
|
||||||
|
this.requireMember(milestone.projectId, actorId);
|
||||||
|
const updated = this.milestoneRepository.update(milestoneId, input);
|
||||||
|
if (!updated) throw new NotFoundError('Milestone', milestoneId);
|
||||||
|
this.activityLogService.logActivity({
|
||||||
|
projectId: milestone.projectId,
|
||||||
|
actorId,
|
||||||
|
action: 'milestone_updated',
|
||||||
|
targetType: 'milestone',
|
||||||
|
targetId: milestoneId,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMilestone(actorId: number, milestoneId: number): void {
|
||||||
|
const milestone = this.milestoneRepository.findById(milestoneId);
|
||||||
|
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
|
||||||
|
this.requireMember(milestone.projectId, actorId);
|
||||||
|
const role = this.projectMemberRepository.getRole(
|
||||||
|
milestone.projectId,
|
||||||
|
actorId
|
||||||
|
);
|
||||||
|
if (role !== 'admin') {
|
||||||
|
throw new ForbiddenError('管理者のみ削除できます');
|
||||||
|
}
|
||||||
|
this.milestoneRepository.delete(milestoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMilestoneProgress(actorId: number, milestoneId: number): number {
|
||||||
|
const milestone = this.milestoneRepository.findById(milestoneId);
|
||||||
|
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
|
||||||
|
this.requireMember(milestone.projectId, actorId);
|
||||||
|
return this.calcMilestoneProgress(milestoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 進捗率: 関連ToDoの完了率(0件時0%) */
|
||||||
|
calcMilestoneProgress(milestoneId: number): number {
|
||||||
|
const todos = this.milestoneRepository.findToDosByMilestone(milestoneId);
|
||||||
|
if (todos.length === 0) return 0;
|
||||||
|
const completed = todos.filter((t) => t.completedAt !== null).length;
|
||||||
|
return Math.round((completed / todos.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireMember(projectId: number, actorId: number): void {
|
||||||
|
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||||
|
throw new ForbiddenError('プロジェクトに参加していません');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/e2e/calendar.spec.ts
Normal file
99
tests/e2e/calendar.spec.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
function unique(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupOwner(page: import('@playwright/test').Page) {
|
||||||
|
const email = unique('owner') + '@example.com';
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||||
|
await page.getByLabel('表示名').fill('Owner');
|
||||||
|
await page.getByLabel('メールアドレス').fill(email);
|
||||||
|
await page.getByLabel('パスワード').fill('password123');
|
||||||
|
await page.getByRole('button', { name: '登録する' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||||
|
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||||
|
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('calendar & milestones', () => {
|
||||||
|
test('milestone progress reflects related todo completion; calendar aggregates', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const projectId = await setupOwner(page);
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const midMonth = `${y}-${m}-15`;
|
||||||
|
|
||||||
|
// マイルストーン作成
|
||||||
|
const msRes = await page.request.post(
|
||||||
|
`/api/projects/${projectId}/milestones`,
|
||||||
|
{ data: { title: unique('MS'), dueDate: midMonth } }
|
||||||
|
);
|
||||||
|
expect(msRes.ok()).toBeTruthy();
|
||||||
|
const { milestone } = (await msRes.json()) as { milestone: { id: number } };
|
||||||
|
|
||||||
|
// ToDoを作成しマイルストーンに紐付け
|
||||||
|
const cols = (
|
||||||
|
(await (
|
||||||
|
await page.request.get(`/api/projects/${projectId}/todos/columns`)
|
||||||
|
).json()) as {
|
||||||
|
columns: { id: number }[];
|
||||||
|
}
|
||||||
|
).columns;
|
||||||
|
const itemRes = await page.request.post(
|
||||||
|
`/api/projects/${projectId}/todos/items`,
|
||||||
|
{
|
||||||
|
data: { title: 'linked task', columnId: cols[0].id, dueDate: midMonth },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { item } = (await itemRes.json()) as { item: { id: number } };
|
||||||
|
await page.request.patch(
|
||||||
|
`/api/projects/${projectId}/todos/items/${item.id}`,
|
||||||
|
{
|
||||||
|
data: { milestoneId: milestone.id },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// マイルストーン画面: 進捗 0%
|
||||||
|
await page.goto(`/projects/${projectId}/milestones`);
|
||||||
|
await expect(page.getByTestId(`milestone-${milestone.id}`)).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`milestone-progress-${milestone.id}`)
|
||||||
|
).toHaveText('0%');
|
||||||
|
|
||||||
|
// ToDo完了 → 進捗 100%
|
||||||
|
await page.request.patch(
|
||||||
|
`/api/projects/${projectId}/todos/items/${item.id}`,
|
||||||
|
{
|
||||||
|
data: { toggleComplete: true },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`milestone-progress-${milestone.id}`)
|
||||||
|
).toHaveText('100%');
|
||||||
|
|
||||||
|
// カレンダーイベント作成
|
||||||
|
const evRes = await page.request.post(
|
||||||
|
`/api/projects/${projectId}/calendar/events`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
title: unique('Event'),
|
||||||
|
type: 'custom',
|
||||||
|
startAt: `${midMonth}T10:00:00`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(evRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// カレンダー画面にイベント+マイルストーン+ToDoが集約表示される
|
||||||
|
await page.goto(`/projects/${projectId}/calendar`);
|
||||||
|
await expect(page.getByText(/マイルストーン:/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/ToDo:/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
93
tests/unit/repositories/CalendarRepository.test.ts
Normal file
93
tests/unit/repositories/CalendarRepository.test.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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 { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
|
|
||||||
|
describe('CalendarRepository', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let repo: CalendarRepository;
|
||||||
|
let projectId: number;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createMigratedTestDb();
|
||||||
|
repo = new CalendarRepository(db);
|
||||||
|
userId = new UserRepository(db).create({
|
||||||
|
name: 'U',
|
||||||
|
email: 'u@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
projectId = new ProjectRepository(db).create({
|
||||||
|
name: 'P',
|
||||||
|
ownerId: userId,
|
||||||
|
}).id;
|
||||||
|
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
function createEvent(title: string, startAt: string) {
|
||||||
|
return repo.create({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
type: 'custom',
|
||||||
|
startAt,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates and finds an event by id', () => {
|
||||||
|
const e = createEvent('E', '2026-06-15T10:00:00');
|
||||||
|
expect(repo.findEventById(e.id)?.title).toBe('E');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds events in range and excludes out-of-range', () => {
|
||||||
|
createEvent('in', '2026-06-15T10:00:00');
|
||||||
|
createEvent('out', '2026-07-15T10:00:00');
|
||||||
|
const events = repo.findByProjectInRange(
|
||||||
|
projectId,
|
||||||
|
'2026-06-01',
|
||||||
|
'2026-06-30'
|
||||||
|
);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].title).toBe('in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes soft-deleted events', () => {
|
||||||
|
const e = createEvent('E', '2026-06-15T10:00:00');
|
||||||
|
repo.delete(e.id);
|
||||||
|
expect(repo.findEventById(e.id)).toBeNull();
|
||||||
|
expect(
|
||||||
|
repo.findByProjectInRange(projectId, '2026-06-01', '2026-06-30')
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates events by project', () => {
|
||||||
|
createEvent('mine', '2026-06-15T10:00:00');
|
||||||
|
const p2 = new ProjectRepository(db).create({
|
||||||
|
name: 'P2',
|
||||||
|
ownerId: userId,
|
||||||
|
}).id;
|
||||||
|
repo.create({
|
||||||
|
projectId: p2,
|
||||||
|
title: 'theirs',
|
||||||
|
type: 'custom',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
repo.findByProjectInRange(projectId, '2026-06-01', '2026-06-30')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
repo.findByProjectInRange(p2, '2026-06-01', '2026-06-30')
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an event', () => {
|
||||||
|
const e = createEvent('E', '2026-06-15T10:00:00');
|
||||||
|
expect(repo.update(e.id, { title: 'E2' })?.title).toBe('E2');
|
||||||
|
});
|
||||||
|
});
|
||||||
84
tests/unit/repositories/MilestoneRepository.test.ts
Normal file
84
tests/unit/repositories/MilestoneRepository.test.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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 { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||||
|
|
||||||
|
describe('MilestoneRepository', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let repo: MilestoneRepository;
|
||||||
|
let todoRepo: TodoRepository;
|
||||||
|
let projectId: number;
|
||||||
|
let userId: number;
|
||||||
|
let columnId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createMigratedTestDb();
|
||||||
|
repo = new MilestoneRepository(db);
|
||||||
|
todoRepo = new TodoRepository(db);
|
||||||
|
userId = new UserRepository(db).create({
|
||||||
|
name: 'U',
|
||||||
|
email: 'u@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
projectId = new ProjectRepository(db).create({
|
||||||
|
name: 'P',
|
||||||
|
ownerId: userId,
|
||||||
|
}).id;
|
||||||
|
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||||
|
columnId = todoRepo.createColumn({
|
||||||
|
projectId,
|
||||||
|
name: 'Todo',
|
||||||
|
orderIndex: 0,
|
||||||
|
}).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
it('creates and lists milestones ordered by due_date', () => {
|
||||||
|
repo.create({ projectId, title: 'M2', dueDate: '2026-12-31' });
|
||||||
|
repo.create({ projectId, title: 'M1', dueDate: '2026-06-30' });
|
||||||
|
const list = repo.findByProject(projectId);
|
||||||
|
expect(list.map((m) => m.title)).toEqual(['M1', 'M2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates and deletes a milestone', () => {
|
||||||
|
const m = repo.create({ projectId, title: 'M' });
|
||||||
|
expect(repo.update(m.id, { status: 'closed' })?.status).toBe('closed');
|
||||||
|
repo.delete(m.id);
|
||||||
|
expect(repo.findById(m.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes soft-deleted milestones', () => {
|
||||||
|
const m = repo.create({ projectId, title: 'M' });
|
||||||
|
repo.delete(m.id);
|
||||||
|
expect(repo.findByProject(projectId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findToDosByMilestone returns only linked, non-deleted todos', () => {
|
||||||
|
const m = repo.create({ projectId, title: 'M' });
|
||||||
|
const t1 = todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 't1',
|
||||||
|
creatorId: userId,
|
||||||
|
orderIndex: 0,
|
||||||
|
});
|
||||||
|
const t2 = todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 't2',
|
||||||
|
creatorId: userId,
|
||||||
|
orderIndex: 1,
|
||||||
|
});
|
||||||
|
todoRepo.updateItem(t1.id, { milestoneId: m.id });
|
||||||
|
todoRepo.updateItem(t2.id, { milestoneId: m.id });
|
||||||
|
todoRepo.deleteItem(t2.id); // soft delete
|
||||||
|
const todos = repo.findToDosByMilestone(m.id);
|
||||||
|
expect(todos).toHaveLength(1);
|
||||||
|
expect(todos[0].id).toBe(t1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
tests/unit/services/ScheduleService.test.ts
Normal file
183
tests/unit/services/ScheduleService.test.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
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 { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
|
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||||
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||||
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
import { ScheduleService } from '@/services/ScheduleService';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
function makeService(db: SqliteDatabase) {
|
||||||
|
return new ScheduleService(
|
||||||
|
new CalendarRepository(db),
|
||||||
|
new MilestoneRepository(db),
|
||||||
|
new TodoRepository(db),
|
||||||
|
new ProjectMemberRepository(db),
|
||||||
|
new ActivityLogService(new ActivityLogRepository(db))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScheduleService', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let service: ScheduleService;
|
||||||
|
let projectId: number;
|
||||||
|
let authorId: number;
|
||||||
|
let outsiderId: number;
|
||||||
|
let columnId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createMigratedTestDb();
|
||||||
|
service = makeService(db);
|
||||||
|
const users = new UserRepository(db);
|
||||||
|
authorId = users.create({
|
||||||
|
name: 'A',
|
||||||
|
email: 'a@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
outsiderId = users.create({
|
||||||
|
name: 'O',
|
||||||
|
email: 'o@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
projectId = new ProjectRepository(db).create({
|
||||||
|
name: 'P',
|
||||||
|
ownerId: authorId,
|
||||||
|
}).id;
|
||||||
|
const members = new ProjectMemberRepository(db);
|
||||||
|
members.add(projectId, authorId, 'admin');
|
||||||
|
columnId = new TodoRepository(db).createColumn({
|
||||||
|
projectId,
|
||||||
|
name: 'Todo',
|
||||||
|
orderIndex: 0,
|
||||||
|
}).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
describe('getCalendarEvents (aggregation)', () => {
|
||||||
|
it('aggregates custom events + milestones + todos within range', () => {
|
||||||
|
service.createEvent(authorId, {
|
||||||
|
projectId,
|
||||||
|
title: 'Meeting',
|
||||||
|
type: 'meeting',
|
||||||
|
startAt: '2026-06-10T10:00:00',
|
||||||
|
});
|
||||||
|
service.createMilestone(authorId, projectId, {
|
||||||
|
title: 'M1',
|
||||||
|
dueDate: '2026-06-20',
|
||||||
|
});
|
||||||
|
const todoRepo = new TodoRepository(db);
|
||||||
|
todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 'task',
|
||||||
|
creatorId: authorId,
|
||||||
|
orderIndex: 0,
|
||||||
|
dueDate: '2026-06-15',
|
||||||
|
});
|
||||||
|
const views = service.getCalendarEvents(authorId, projectId, {
|
||||||
|
from: '2026-06-01',
|
||||||
|
to: '2026-06-30',
|
||||||
|
});
|
||||||
|
const sources = views.map((v) => v.source).sort();
|
||||||
|
expect(sources).toEqual(['event', 'milestone', 'todo']);
|
||||||
|
expect(views[0].startAt).toBe('2026-06-10T10:00:00'); // earliest first
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes items outside the range', () => {
|
||||||
|
service.createMilestone(authorId, projectId, {
|
||||||
|
title: 'M',
|
||||||
|
dueDate: '2026-07-15',
|
||||||
|
});
|
||||||
|
const views = service.getCalendarEvents(authorId, projectId, {
|
||||||
|
from: '2026-06-01',
|
||||||
|
to: '2026-06-30',
|
||||||
|
});
|
||||||
|
expect(views).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a non-member', () => {
|
||||||
|
expect(() =>
|
||||||
|
service.getCalendarEvents(outsiderId, projectId, {
|
||||||
|
from: '2026-06-01',
|
||||||
|
to: '2026-06-30',
|
||||||
|
})
|
||||||
|
).toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('milestone progress', () => {
|
||||||
|
it('returns 0% when there are no related todos', () => {
|
||||||
|
const m = service.createMilestone(authorId, projectId, { title: 'M' });
|
||||||
|
expect(service.calcMilestoneProgress(m.id)).toBe(0);
|
||||||
|
expect(service.getMilestoneProgress(authorId, m.id)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the completion ratio rounded', () => {
|
||||||
|
const m = service.createMilestone(authorId, projectId, { title: 'M' });
|
||||||
|
const todoRepo = new TodoRepository(db);
|
||||||
|
const t1 = todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 'a',
|
||||||
|
creatorId: authorId,
|
||||||
|
orderIndex: 0,
|
||||||
|
});
|
||||||
|
const t2 = todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 'b',
|
||||||
|
creatorId: authorId,
|
||||||
|
orderIndex: 1,
|
||||||
|
});
|
||||||
|
const t3 = todoRepo.createItem({
|
||||||
|
projectId,
|
||||||
|
columnId,
|
||||||
|
title: 'c',
|
||||||
|
creatorId: authorId,
|
||||||
|
orderIndex: 2,
|
||||||
|
});
|
||||||
|
for (const t of [t1, t2, t3])
|
||||||
|
todoRepo.updateItem(t.id, { milestoneId: m.id });
|
||||||
|
// 1/3 完了
|
||||||
|
todoRepo.updateItem(t1.id, { completedAt: '2026-06-01T00:00:00.000Z' });
|
||||||
|
expect(service.calcMilestoneProgress(m.id)).toBe(33);
|
||||||
|
// 3/3 完了 → 100
|
||||||
|
todoRepo.updateItem(t2.id, { completedAt: '2026-06-02T00:00:00.000Z' });
|
||||||
|
todoRepo.updateItem(t3.id, { completedAt: '2026-06-03T00:00:00.000Z' });
|
||||||
|
expect(service.calcMilestoneProgress(m.id)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMilestones attaches progress', () => {
|
||||||
|
const m = service.createMilestone(authorId, projectId, { title: 'M' });
|
||||||
|
const list = service.getMilestones(authorId, projectId);
|
||||||
|
expect(list[0].progress).toBe(0);
|
||||||
|
expect(list[0].id).toBe(m.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('milestone CRUD', () => {
|
||||||
|
it('update logs milestone_updated activity', () => {
|
||||||
|
const m = service.createMilestone(authorId, projectId, { title: 'M' });
|
||||||
|
service.updateMilestone(authorId, m.id, { status: 'closed' });
|
||||||
|
expect(
|
||||||
|
new ActivityLogRepository(db)
|
||||||
|
.findByProject(projectId)
|
||||||
|
.items.some((l) => l.action === 'milestone_updated')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete requires admin', () => {
|
||||||
|
const m = service.createMilestone(authorId, projectId, { title: 'M' });
|
||||||
|
service.deleteMilestone(authorId, m.id); // authorId is admin
|
||||||
|
expect(() =>
|
||||||
|
service.updateMilestone(authorId, m.id, { title: 'x' })
|
||||||
|
).toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user