feat(m12): meetings + schedule conflict detection, tests, e2e
- MeetingRepository (CRUD, soft-delete, members, findMeetingsByUserInRange overlap) + CalendarRepository.findByCreatorInRange + TodoRepository. findHighPriorityByAssignee - MeetingService (createMeeting tx+members, meeting_invited notify, meeting_created activity, SSE meeting.created; updateMeeting/updateMinutes/ deleteMeeting; checkScheduleConflicts = other meetings overlap + calendar events overlap + high-priority todos due +-3d, time-overlap logic, excludeMeetingId; conflicts are warnings) - APIs: meetings list/create/edit + /check conflict check - Screens: meetings page + MeetingForm(member checkboxes, conflict display) + ConflictWarning, ProjectNav link - Unit tests (MeetingRepository, MeetingService incl. conflict algorithm) + e2e meetings
This commit is contained in:
74
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
74
app/api/projects/[projectId]/meetings/[id]/route.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService } 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 = createMeetingService();
|
||||||
|
try {
|
||||||
|
if (body.minutesMd !== undefined) {
|
||||||
|
const meeting = service.updateMinutes(
|
||||||
|
user.id,
|
||||||
|
Number(id),
|
||||||
|
String(body.minutesMd ?? '')
|
||||||
|
);
|
||||||
|
return NextResponse.json({ meeting });
|
||||||
|
}
|
||||||
|
const meeting = service.updateMeeting(user.id, Number(id), {
|
||||||
|
title: typeof body.title === 'string' ? body.title : undefined,
|
||||||
|
startAt: typeof body.startAt === 'string' ? body.startAt : undefined,
|
||||||
|
endAt: typeof body.endAt === 'string' ? body.endAt : undefined,
|
||||||
|
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : undefined,
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ meeting });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; id: string }> }
|
||||||
|
) {
|
||||||
|
// /api/projects/:projectId/meetings/check は専用ルートで処理するが、
|
||||||
|
// ここでは meetingId='check' の場合は重複チェックのみを行う
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return handleApiError(new UnauthorizedError());
|
||||||
|
const { projectId, id } = await params;
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonError(400, 'リクエスト本文が不正です');
|
||||||
|
}
|
||||||
|
const service = createMeetingService();
|
||||||
|
try {
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
Number(projectId),
|
||||||
|
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
|
||||||
|
String(body.startAt ?? ''),
|
||||||
|
String(body.endAt ?? ''),
|
||||||
|
id === 'check' ? undefined : Number(id)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ conflicts });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
58
app/api/projects/[projectId]/meetings/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService } 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 = createMeetingService();
|
||||||
|
try {
|
||||||
|
return NextResponse.json({
|
||||||
|
meetings: service.getMeetings(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 = createMeetingService();
|
||||||
|
try {
|
||||||
|
const result = service.createMeeting(user.id, Number(projectId), {
|
||||||
|
title: String(body.title ?? ''),
|
||||||
|
startAt: String(body.startAt ?? ''),
|
||||||
|
endAt: String(body.endAt ?? ''),
|
||||||
|
description:
|
||||||
|
typeof body.description === 'string' ? body.description : null,
|
||||||
|
location: typeof body.location === 'string' ? body.location : null,
|
||||||
|
meetingUrl: typeof body.meetingUrl === 'string' ? body.meetingUrl : null,
|
||||||
|
agendaMd: typeof body.agendaMd === 'string' ? body.agendaMd : null,
|
||||||
|
memberIds: Array.isArray(body.memberIds)
|
||||||
|
? body.memberIds.map(Number)
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/projects/[projectId]/meetings/page.tsx
Normal file
72
app/projects/[projectId]/meetings/page.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { createMeetingService, createProjectService } from '@/lib/api/services';
|
||||||
|
import { Header } from '@/components/layout/Header';
|
||||||
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
|
import { MeetingForm } from '@/components/meetings/MeetingForm';
|
||||||
|
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function MeetingsPage({
|
||||||
|
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 meetingService = createMeetingService();
|
||||||
|
const meetings = meetingService.getMeetings(user.id, project.id);
|
||||||
|
const members = projectService.getMembers(user.id, project.id).map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
name: m.user.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="meetings" />
|
||||||
|
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">ミーティング</h1>
|
||||||
|
<MeetingForm projectId={project.id} members={members} />
|
||||||
|
<section className="space-y-3">
|
||||||
|
{meetings.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">ミーティングはありません。</p>
|
||||||
|
) : (
|
||||||
|
meetings.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||||
|
data-testid={`meeting-${m.id}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-gray-800">{m.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{m.startAt} 〜 {m.endAt}
|
||||||
|
</p>
|
||||||
|
{m.location && (
|
||||||
|
<p className="text-xs text-gray-500">場所: {m.location}</p>
|
||||||
|
)}
|
||||||
|
{m.minutesMd && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600">議事録あり</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/files', label: 'ファイル' },
|
{ href: '/files', label: 'ファイル' },
|
||||||
{ href: '/calendar', label: 'カレンダー' },
|
{ href: '/calendar', label: 'カレンダー' },
|
||||||
{ href: '/milestones', label: 'マイルストーン' },
|
{ href: '/milestones', label: 'マイルストーン' },
|
||||||
|
{ href: '/meetings', label: 'ミーティング' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
{ href: '/activity', label: 'アクティビティ' },
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
@ -30,6 +31,7 @@ export function ProjectNav({
|
|||||||
| 'files'
|
| 'files'
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'milestones'
|
| 'milestones'
|
||||||
|
| 'meetings'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'settings';
|
| 'settings';
|
||||||
@ -43,6 +45,7 @@ export function ProjectNav({
|
|||||||
files: active === 'files',
|
files: active === 'files',
|
||||||
calendar: active === 'calendar',
|
calendar: active === 'calendar',
|
||||||
milestones: active === 'milestones',
|
milestones: active === 'milestones',
|
||||||
|
meetings: active === 'meetings',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
activity: active === 'activity',
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
|
|||||||
46
components/meetings/ConflictWarning.tsx
Normal file
46
components/meetings/ConflictWarning.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface ScheduleConflict {
|
||||||
|
userId: number;
|
||||||
|
type: 'meeting' | 'calendar_event' | 'important_todo';
|
||||||
|
refId: number;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
meeting: 'ミーティング',
|
||||||
|
calendar_event: 'カレンダーイベント',
|
||||||
|
important_todo: '期限の近い重要タスク',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConflictWarning({
|
||||||
|
conflicts,
|
||||||
|
}: {
|
||||||
|
conflicts: ScheduleConflict[];
|
||||||
|
}) {
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-green-600" data-testid="no-conflicts">
|
||||||
|
スケジュールの重複はありません。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded border border-yellow-300 bg-yellow-50 p-3"
|
||||||
|
data-testid="conflict-warning"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-yellow-800">
|
||||||
|
スケジュールの重複があります(作成は完了しています):
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 list-inside list-disc text-sm text-yellow-800">
|
||||||
|
{conflicts.map((c, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
[{TYPE_LABELS[c.type] ?? c.type}] {c.title}({c.startAt}
|
||||||
|
{c.endAt ? ` 〜 ${c.endAt}` : ''})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
components/meetings/MeetingForm.tsx
Normal file
143
components/meetings/MeetingForm.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
ConflictWarning,
|
||||||
|
type ScheduleConflict,
|
||||||
|
} from '@/components/meetings/ConflictWarning';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingForm({
|
||||||
|
projectId,
|
||||||
|
members,
|
||||||
|
}: {
|
||||||
|
projectId: number;
|
||||||
|
members: Member[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [startAt, setStartAt] = useState('');
|
||||||
|
const [endAt, setEndAt] = useState('');
|
||||||
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
|
const [conflicts, setConflicts] = useState<ScheduleConflict[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setConflicts(null);
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/meetings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, startAt, endAt, memberIds: selected }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
meeting: { id: number };
|
||||||
|
conflicts: ScheduleConflict[];
|
||||||
|
};
|
||||||
|
setConflicts(data.conflicts);
|
||||||
|
setTitle('');
|
||||||
|
setStartAt('');
|
||||||
|
setEndAt('');
|
||||||
|
setSelected([]);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const b = (await res.json().catch(() => null)) as {
|
||||||
|
error?: { message?: string };
|
||||||
|
} | null;
|
||||||
|
setError(b?.error?.message ?? '作成に失敗しました');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMember(userId: number) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(userId)
|
||||||
|
? prev.filter((u) => u !== userId)
|
||||||
|
: [...prev, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="space-y-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||||
|
data-testid="meeting-form"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<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
|
||||||
|
data-testid="meeting-title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">開始</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
data-testid="meeting-start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium">終了</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endAt}
|
||||||
|
onChange={(e) => setEndAt(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
required
|
||||||
|
data-testid="meeting-end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">参加メンバー</label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{members.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.userId}
|
||||||
|
className="flex items-center gap-1 rounded border px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(m.userId)}
|
||||||
|
onChange={() => toggleMember(m.userId)}
|
||||||
|
/>
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '作成中...' : 'ミーティング作成'}
|
||||||
|
</button>
|
||||||
|
{conflicts && <ConflictWarning conflicts={conflicts} />}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,6 +21,8 @@ import { FileRepository } from '@/repositories/FileRepository';
|
|||||||
import { ScheduleService } from '@/services/ScheduleService';
|
import { ScheduleService } from '@/services/ScheduleService';
|
||||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||||
|
import { MeetingService } from '@/services/MeetingService';
|
||||||
|
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
||||||
@ -110,6 +112,20 @@ export function createScheduleService(): ScheduleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMeetingService(): MeetingService {
|
||||||
|
const db = getDb();
|
||||||
|
return new MeetingService(
|
||||||
|
new MeetingRepository(db),
|
||||||
|
new CalendarRepository(db),
|
||||||
|
new TodoRepository(db),
|
||||||
|
new ProjectMemberRepository(db),
|
||||||
|
new NotificationService(new NotificationRepository(db)),
|
||||||
|
new ActivityLogService(new ActivityLogRepository(db)),
|
||||||
|
getSseHub(),
|
||||||
|
db
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createUserRepository(): UserRepository {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
return new UserRepository(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,23 @@ export class CalendarRepository {
|
|||||||
return rows.map(mapEvent);
|
return rows.map(mapEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 指定ユーザーが作成したイベントのうち [from,to] と時間重複するもの(スケジュール重複判定用) */
|
||||||
|
findByCreatorInRange(
|
||||||
|
userId: number,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): CalendarEvent[] {
|
||||||
|
const rows = this.db.query<CalendarEventRow>(
|
||||||
|
`SELECT * FROM calendar_events
|
||||||
|
WHERE created_by_id = @userId AND deleted_at IS NULL
|
||||||
|
AND end_at IS NOT NULL
|
||||||
|
AND start_at <= @to AND end_at >= @from
|
||||||
|
ORDER BY start_at ASC`,
|
||||||
|
{ userId, from, to }
|
||||||
|
);
|
||||||
|
return rows.map(mapEvent);
|
||||||
|
}
|
||||||
|
|
||||||
findEventById(id: number): CalendarEvent | null {
|
findEventById(id: number): CalendarEvent | null {
|
||||||
const row = this.db.get<CalendarEventRow>(
|
const row = this.db.get<CalendarEventRow>(
|
||||||
'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL',
|
'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
|||||||
226
repositories/MeetingRepository.ts
Normal file
226
repositories/MeetingRepository.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
import type { Meeting, MeetingMember, MeetingMemberStatus } from '@/lib/types';
|
||||||
|
|
||||||
|
interface MeetingRow {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
location: string | null;
|
||||||
|
meeting_url: string | null;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
agenda_md: string | null;
|
||||||
|
minutes_md: string | null;
|
||||||
|
created_by_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeetingMemberRow {
|
||||||
|
id: number;
|
||||||
|
meeting_id: number;
|
||||||
|
user_id: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMeeting(row: MeetingRow): Meeting {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
location: row.location,
|
||||||
|
meetingUrl: row.meeting_url,
|
||||||
|
startAt: row.start_at,
|
||||||
|
endAt: row.end_at,
|
||||||
|
agendaMd: row.agenda_md,
|
||||||
|
minutesMd: row.minutes_md,
|
||||||
|
createdById: row.created_by_id,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
deletedAt: row.deleted_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMember(row: MeetingMemberRow): MeetingMember {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
meetingId: row.meeting_id,
|
||||||
|
userId: row.user_id,
|
||||||
|
status: row.status as MeetingMemberStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMeetingInput {
|
||||||
|
projectId: number;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
meetingUrl?: string | null;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
agendaMd?: string | null;
|
||||||
|
createdById: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMeetingInput {
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
meetingUrl?: string | null;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
agendaMd?: string | null;
|
||||||
|
minutesMd?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeetingRepository {
|
||||||
|
constructor(private readonly db: SqliteDatabase) {}
|
||||||
|
|
||||||
|
findByProject(projectId: number): Meeting[] {
|
||||||
|
const rows = this.db.query<MeetingRow>(
|
||||||
|
'SELECT * FROM meetings WHERE project_id = @projectId AND deleted_at IS NULL ORDER BY start_at ASC, id ASC',
|
||||||
|
{ projectId }
|
||||||
|
);
|
||||||
|
return rows.map(mapMeeting);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: number): Meeting | null {
|
||||||
|
const row = this.db.get<MeetingRow>(
|
||||||
|
'SELECT * FROM meetings WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ id }
|
||||||
|
);
|
||||||
|
return row ? mapMeeting(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateMeetingInput): Meeting {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = this.db.execute(
|
||||||
|
`INSERT INTO meetings (project_id, title, description, location, meeting_url, start_at, end_at, agenda_md, minutes_md, created_by_id, created_at, updated_at, deleted_at)
|
||||||
|
VALUES (@projectId, @title, @description, @location, @meetingUrl, @startAt, @endAt, @agendaMd, NULL, @createdById, @createdAt, @updatedAt, NULL)`,
|
||||||
|
{
|
||||||
|
projectId: input.projectId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
location: input.location ?? null,
|
||||||
|
meetingUrl: input.meetingUrl ?? null,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
agendaMd: input.agendaMd ?? null,
|
||||||
|
createdById: input.createdById,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const created = this.findById(Number(result.lastInsertRowid));
|
||||||
|
if (!created) throw new Error('Failed to create meeting');
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: number, input: UpdateMeetingInput): Meeting | 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.location !== undefined) {
|
||||||
|
fields.push('location = @location');
|
||||||
|
params.location = input.location;
|
||||||
|
}
|
||||||
|
if (input.meetingUrl !== undefined) {
|
||||||
|
fields.push('meeting_url = @meetingUrl');
|
||||||
|
params.meetingUrl = input.meetingUrl;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (input.agendaMd !== undefined) {
|
||||||
|
fields.push('agenda_md = @agendaMd');
|
||||||
|
params.agendaMd = input.agendaMd;
|
||||||
|
}
|
||||||
|
if (input.minutesMd !== undefined) {
|
||||||
|
fields.push('minutes_md = @minutesMd');
|
||||||
|
params.minutesMd = input.minutesMd;
|
||||||
|
}
|
||||||
|
this.db.execute(
|
||||||
|
`UPDATE meetings 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 meetings SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
{ now: new Date().toISOString(), id }
|
||||||
|
);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- members -----
|
||||||
|
|
||||||
|
findMembersByMeeting(meetingId: number): MeetingMember[] {
|
||||||
|
const rows = this.db.query<MeetingMemberRow>(
|
||||||
|
'SELECT * FROM meeting_members WHERE meeting_id = @meetingId ORDER BY id ASC',
|
||||||
|
{ meetingId }
|
||||||
|
);
|
||||||
|
return rows.map(mapMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMember(meetingId: number, userId: number): MeetingMember {
|
||||||
|
const result = this.db.execute(
|
||||||
|
`INSERT INTO meeting_members (meeting_id, user_id, status) VALUES (@meetingId, @userId, 'invited')`,
|
||||||
|
{ meetingId, userId }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: Number(result.lastInsertRowid),
|
||||||
|
meetingId,
|
||||||
|
userId,
|
||||||
|
status: 'invited',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMember(meetingId: number, userId: number): boolean {
|
||||||
|
const result = this.db.execute(
|
||||||
|
'DELETE FROM meeting_members WHERE meeting_id = @meetingId AND user_id = @userId',
|
||||||
|
{ meetingId, userId }
|
||||||
|
);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ユーザーが参加するミーティングのうち、[from,to]と時間重複するもの(重複判定用) */
|
||||||
|
findMeetingsByUserInRange(
|
||||||
|
userId: number,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
excludeMeetingId?: number
|
||||||
|
): Meeting[] {
|
||||||
|
const rows = this.db.query<MeetingRow>(
|
||||||
|
`SELECT m.* FROM meetings m
|
||||||
|
INNER JOIN meeting_members mm ON mm.meeting_id = m.id
|
||||||
|
WHERE mm.user_id = @userId AND m.deleted_at IS NULL
|
||||||
|
AND m.start_at <= @to AND m.end_at >= @from
|
||||||
|
ORDER BY m.start_at ASC`,
|
||||||
|
{ userId, from, to }
|
||||||
|
);
|
||||||
|
const meetings = rows.map(mapMeeting);
|
||||||
|
return excludeMeetingId !== undefined
|
||||||
|
? meetings.filter((m) => m.id !== excludeMeetingId)
|
||||||
|
: meetings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -271,4 +271,21 @@ export class TodoRepository {
|
|||||||
);
|
);
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 指定期間内に期限を迎える、指定担当者の高優先度ToDo(スケジュール重複判定用) */
|
||||||
|
findHighPriorityByAssignee(
|
||||||
|
projectId: number,
|
||||||
|
assigneeId: number,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): TodoItem[] {
|
||||||
|
const rows = this.db.query<TodoItemRow>(
|
||||||
|
`SELECT * FROM todo_items
|
||||||
|
WHERE project_id = @projectId AND assignee_id = @assigneeId
|
||||||
|
AND priority = 'high' AND deleted_at IS NULL
|
||||||
|
AND due_date IS NOT NULL AND due_date >= @from AND due_date <= @to`,
|
||||||
|
{ projectId, assigneeId, from, to }
|
||||||
|
);
|
||||||
|
return rows.map(mapItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
290
services/MeetingService.ts
Normal file
290
services/MeetingService.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||||
|
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||||
|
import { NotificationService } from '@/services/NotificationService';
|
||||||
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
import { SseHub } from '@/lib/sse/hub';
|
||||||
|
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import type { Meeting, MeetingMember } from '@/lib/types';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface ScheduleConflict {
|
||||||
|
userId: number;
|
||||||
|
type: 'meeting' | 'calendar_event' | 'important_todo';
|
||||||
|
refId: number;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMeetingRequest {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
meetingUrl?: string | null;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
agendaMd?: string | null;
|
||||||
|
memberIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ミーティング管理とスケジュール重複判定を担うService。
|
||||||
|
*/
|
||||||
|
export class MeetingService {
|
||||||
|
constructor(
|
||||||
|
private readonly meetingRepository: MeetingRepository,
|
||||||
|
private readonly calendarRepository: CalendarRepository,
|
||||||
|
private readonly todoRepository: TodoRepository,
|
||||||
|
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly activityLogService: ActivityLogService,
|
||||||
|
private readonly sseHub: SseHub,
|
||||||
|
private readonly db: SqliteDatabase
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getMeetings(actorId: number, projectId: number): Meeting[] {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
return this.meetingRepository.findByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMeeting(
|
||||||
|
actorId: number,
|
||||||
|
meetingId: number
|
||||||
|
): {
|
||||||
|
meeting: Meeting;
|
||||||
|
members: MeetingMember[];
|
||||||
|
} {
|
||||||
|
const meeting = this.meetingRepository.findById(meetingId);
|
||||||
|
if (!meeting) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
this.requireMember(meeting.projectId, actorId);
|
||||||
|
return {
|
||||||
|
meeting,
|
||||||
|
members: this.meetingRepository.findMembersByMeeting(meetingId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createMeeting(
|
||||||
|
actorId: number,
|
||||||
|
projectId: number,
|
||||||
|
input: CreateMeetingRequest
|
||||||
|
): { meeting: Meeting; conflicts: ScheduleConflict[] } {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
|
this.validate(input);
|
||||||
|
const conflicts = this.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
input.memberIds,
|
||||||
|
input.startAt,
|
||||||
|
input.endAt
|
||||||
|
);
|
||||||
|
|
||||||
|
const meeting = this.db.transaction(() => {
|
||||||
|
const created = this.meetingRepository.create({
|
||||||
|
projectId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
location: input.location ?? null,
|
||||||
|
meetingUrl: input.meetingUrl ?? null,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
agendaMd: input.agendaMd ?? null,
|
||||||
|
createdById: actorId,
|
||||||
|
});
|
||||||
|
for (const userId of input.memberIds) {
|
||||||
|
this.meetingRepository.addMember(created.id, userId);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.memberIds.length > 0) {
|
||||||
|
this.notificationService.notifyOnEvent({
|
||||||
|
type: 'meeting_invited',
|
||||||
|
projectId,
|
||||||
|
title: `ミーティング「${meeting.title}」に招待されました`,
|
||||||
|
body: `${meeting.startAt} 〜 ${meeting.endAt}`,
|
||||||
|
memberIds: input.memberIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.activityLogService.logActivity({
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
action: 'meeting_created',
|
||||||
|
targetType: 'meeting',
|
||||||
|
targetId: meeting.id,
|
||||||
|
});
|
||||||
|
this.sseHub.broadcast(projectId, {
|
||||||
|
type: 'meeting.created',
|
||||||
|
data: { projectId },
|
||||||
|
});
|
||||||
|
return { meeting, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMeeting(
|
||||||
|
actorId: number,
|
||||||
|
meetingId: number,
|
||||||
|
input: Partial<CreateMeetingRequest>
|
||||||
|
): Meeting {
|
||||||
|
const meeting = this.meetingRepository.findById(meetingId);
|
||||||
|
if (!meeting) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
this.requireMember(meeting.projectId, actorId);
|
||||||
|
const updated = this.meetingRepository.update(meetingId, {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
location: input.location,
|
||||||
|
meetingUrl: input.meetingUrl,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
agendaMd: input.agendaMd,
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMinutes(
|
||||||
|
actorId: number,
|
||||||
|
meetingId: number,
|
||||||
|
minutesMd: string
|
||||||
|
): Meeting {
|
||||||
|
const meeting = this.meetingRepository.findById(meetingId);
|
||||||
|
if (!meeting) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
this.requireMember(meeting.projectId, actorId);
|
||||||
|
const updated = this.meetingRepository.update(meetingId, { minutesMd });
|
||||||
|
if (!updated) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMeeting(actorId: number, meetingId: number): void {
|
||||||
|
const meeting = this.meetingRepository.findById(meetingId);
|
||||||
|
if (!meeting) throw new NotFoundError('Meeting', meetingId);
|
||||||
|
const role = this.projectMemberRepository.getRole(
|
||||||
|
meeting.projectId,
|
||||||
|
actorId
|
||||||
|
);
|
||||||
|
if (meeting.createdById !== actorId && role !== 'admin') {
|
||||||
|
throw new ForbiddenError('作成者または管理者のみ削除できます');
|
||||||
|
}
|
||||||
|
this.meetingRepository.delete(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* スケジュール重複判定:
|
||||||
|
* 各メンバーについて (1)他ミーティング (2)カレンダーイベント (3)期限±3日の重要ToDo を検出。
|
||||||
|
* 重複は警告(作成ブロックしない)。時間重複 = NOT(existing.end <= new.start OR existing.start >= new.end)
|
||||||
|
*/
|
||||||
|
checkScheduleConflicts(
|
||||||
|
projectId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
startAt: string,
|
||||||
|
endAt: string,
|
||||||
|
excludeMeetingId?: number
|
||||||
|
): ScheduleConflict[] {
|
||||||
|
const conflicts: ScheduleConflict[] = [];
|
||||||
|
const newStart = new Date(startAt).getTime();
|
||||||
|
const newEnd = new Date(endAt).getTime();
|
||||||
|
const fromDate = new Date(newStart - 3 * DAY_MS);
|
||||||
|
const toDate = new Date(newStart + 3 * DAY_MS);
|
||||||
|
const fromDay = this.toDateStr(fromDate);
|
||||||
|
const toDay = this.toDateStr(toDate);
|
||||||
|
|
||||||
|
for (const userId of memberIds) {
|
||||||
|
// 1. 他ミーティング(meeting_members経由・時間重複)
|
||||||
|
const meetings = this.meetingRepository.findMeetingsByUserInRange(
|
||||||
|
userId,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
excludeMeetingId
|
||||||
|
);
|
||||||
|
for (const m of meetings) {
|
||||||
|
if (this.overlaps(newStart, newEnd, m.startAt, m.endAt)) {
|
||||||
|
conflicts.push({
|
||||||
|
userId,
|
||||||
|
type: 'meeting',
|
||||||
|
refId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
startAt: m.startAt,
|
||||||
|
endAt: m.endAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. カレンダーイベント(本人作成・時間重複)
|
||||||
|
const events = this.calendarRepository.findByCreatorInRange(
|
||||||
|
userId,
|
||||||
|
startAt,
|
||||||
|
endAt
|
||||||
|
);
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.endAt && this.overlaps(newStart, newEnd, e.startAt, e.endAt)) {
|
||||||
|
conflicts.push({
|
||||||
|
userId,
|
||||||
|
type: 'calendar_event',
|
||||||
|
refId: e.id,
|
||||||
|
title: e.title,
|
||||||
|
startAt: e.startAt,
|
||||||
|
endAt: e.endAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 期限±3日の重要タスク(priority=high)
|
||||||
|
const todos = this.todoRepository.findHighPriorityByAssignee(
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
fromDay,
|
||||||
|
toDay
|
||||||
|
);
|
||||||
|
for (const t of todos) {
|
||||||
|
conflicts.push({
|
||||||
|
userId,
|
||||||
|
type: 'important_todo',
|
||||||
|
refId: t.id,
|
||||||
|
title: t.title,
|
||||||
|
startAt: t.dueDate ?? '',
|
||||||
|
endAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private overlaps(
|
||||||
|
newStart: number,
|
||||||
|
newEnd: number,
|
||||||
|
existingStart: string,
|
||||||
|
existingEnd: string
|
||||||
|
): boolean {
|
||||||
|
const s = new Date(existingStart).getTime();
|
||||||
|
const e = new Date(existingEnd).getTime();
|
||||||
|
return !(e <= newStart || s >= newEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDateStr(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
d.getDate()
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireMember(projectId: number, actorId: number): void {
|
||||||
|
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||||
|
throw new ForbiddenError('プロジェクトに参加していません');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate(input: CreateMeetingRequest): void {
|
||||||
|
if (!input.title.trim()) {
|
||||||
|
throw new ValidationError('タイトルを入力してください', 'title');
|
||||||
|
}
|
||||||
|
if (!input.startAt || !input.endAt) {
|
||||||
|
throw new ValidationError('開始/終了日時を入力してください', 'startAt');
|
||||||
|
}
|
||||||
|
if (new Date(input.startAt) >= new Date(input.endAt)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'終了日時は開始日時より後にしてください',
|
||||||
|
'endAt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
tests/e2e/meetings.spec.ts
Normal file
77
tests/e2e/meetings.spec.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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('meetings & schedule conflict', () => {
|
||||||
|
test('create meetings and detect a schedule conflict for a member', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const projectId = await setupOwner(page);
|
||||||
|
const memberEmail = unique('member') + '@example.com';
|
||||||
|
|
||||||
|
// メンバー登録してプロジェクトに追加
|
||||||
|
await request.post('/api/auth/register', {
|
||||||
|
data: { name: 'Member', email: memberEmail, password: 'password123' },
|
||||||
|
});
|
||||||
|
const addRes = await page.request.post(
|
||||||
|
`/api/projects/${projectId}/members`,
|
||||||
|
{ data: { email: memberEmail, role: 'member' } }
|
||||||
|
);
|
||||||
|
const memberId = ((await addRes.json()) as { member: { userId: number } })
|
||||||
|
.member.userId;
|
||||||
|
|
||||||
|
// 1件目ミーティング(10:00-11:00)に member 参加
|
||||||
|
const m1 = await page.request.post(`/api/projects/${projectId}/meetings`, {
|
||||||
|
data: {
|
||||||
|
title: unique('M1'),
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(m1.ok()).toBeTruthy();
|
||||||
|
expect(((await m1.json()) as { conflicts: unknown[] }).conflicts).toEqual(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2件目(10:30-11:30)は member にとって重複 → 警告が返る
|
||||||
|
const m2 = await page.request.post(`/api/projects/${projectId}/meetings`, {
|
||||||
|
data: {
|
||||||
|
title: unique('M2'),
|
||||||
|
startAt: '2026-06-15T10:30:00',
|
||||||
|
endAt: '2026-06-15T11:30:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(m2.ok()).toBeTruthy();
|
||||||
|
const m2Body = (await m2.json()) as {
|
||||||
|
meeting: { id: number };
|
||||||
|
conflicts: { type: string }[];
|
||||||
|
};
|
||||||
|
expect(m2Body.conflicts.some((c) => c.type === 'meeting')).toBe(true);
|
||||||
|
|
||||||
|
// ミーティング一覧画面に両方表示
|
||||||
|
await page.goto(`/projects/${projectId}/meetings`);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`meeting-${m2Body.meeting.id}`)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
tests/unit/repositories/MeetingRepository.test.ts
Normal file
98
tests/unit/repositories/MeetingRepository.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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 { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||||
|
|
||||||
|
describe('MeetingRepository', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let repo: MeetingRepository;
|
||||||
|
let projectId: number;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createMigratedTestDb();
|
||||||
|
repo = new MeetingRepository(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 createMeeting(title: string, startAt: string, endAt: string) {
|
||||||
|
return repo.create({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
createdById: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates and finds a meeting', () => {
|
||||||
|
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||||
|
expect(repo.findById(m.id)?.title).toBe('M');
|
||||||
|
expect(repo.findByProject(projectId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes soft-deleted meetings', () => {
|
||||||
|
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||||
|
repo.delete(m.id);
|
||||||
|
expect(repo.findById(m.id)).toBeNull();
|
||||||
|
expect(repo.findByProject(projectId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates a meeting', () => {
|
||||||
|
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||||
|
expect(repo.update(m.id, { title: 'M2', minutesMd: 'mins' })?.title).toBe(
|
||||||
|
'M2'
|
||||||
|
);
|
||||||
|
expect(repo.findById(m.id)?.minutesMd).toBe('mins');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and lists members', () => {
|
||||||
|
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||||
|
repo.addMember(m.id, userId);
|
||||||
|
expect(repo.findMembersByMeeting(m.id)).toHaveLength(1);
|
||||||
|
expect(repo.findMembersByMeeting(m.id)[0].status).toBe('invited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a member', () => {
|
||||||
|
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||||
|
repo.addMember(m.id, userId);
|
||||||
|
repo.removeMember(m.id, userId);
|
||||||
|
expect(repo.findMembersByMeeting(m.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findMeetingsByUserInRange returns overlapping meetings and respects excludeMeetingId', () => {
|
||||||
|
const m1 = createMeeting(
|
||||||
|
'overlapping',
|
||||||
|
'2026-06-15T10:00:00',
|
||||||
|
'2026-06-15T11:00:00'
|
||||||
|
);
|
||||||
|
repo.addMember(m1.id, userId);
|
||||||
|
const found = repo.findMeetingsByUserInRange(
|
||||||
|
userId,
|
||||||
|
'2026-06-15T10:30:00',
|
||||||
|
'2026-06-15T11:30:00'
|
||||||
|
);
|
||||||
|
expect(found.map((m) => m.id)).toContain(m1.id);
|
||||||
|
const excluded = repo.findMeetingsByUserInRange(
|
||||||
|
userId,
|
||||||
|
'2026-06-15T10:30:00',
|
||||||
|
'2026-06-15T11:30:00',
|
||||||
|
m1.id
|
||||||
|
);
|
||||||
|
expect(excluded.map((m) => m.id)).not.toContain(m1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
234
tests/unit/services/MeetingService.test.ts
Normal file
234
tests/unit/services/MeetingService.test.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
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 { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||||
|
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||||
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||||
|
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||||
|
import { NotificationService } from '@/services/NotificationService';
|
||||||
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
import { MeetingService } from '@/services/MeetingService';
|
||||||
|
import { SseHub } from '@/lib/sse/hub';
|
||||||
|
import { ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
|
function makeService(db: SqliteDatabase) {
|
||||||
|
return new MeetingService(
|
||||||
|
new MeetingRepository(db),
|
||||||
|
new CalendarRepository(db),
|
||||||
|
new TodoRepository(db),
|
||||||
|
new ProjectMemberRepository(db),
|
||||||
|
new NotificationService(new NotificationRepository(db)),
|
||||||
|
new ActivityLogService(new ActivityLogRepository(db)),
|
||||||
|
new SseHub(),
|
||||||
|
db
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MeetingService', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let service: MeetingService;
|
||||||
|
let projectId: number;
|
||||||
|
let authorId: number;
|
||||||
|
let memberId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createMigratedTestDb();
|
||||||
|
service = makeService(db);
|
||||||
|
const users = new UserRepository(db);
|
||||||
|
authorId = users.create({
|
||||||
|
name: 'A',
|
||||||
|
email: 'a@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
memberId = users.create({
|
||||||
|
name: 'M',
|
||||||
|
email: 'm@example.com',
|
||||||
|
passwordHash: 'h',
|
||||||
|
}).id;
|
||||||
|
projectId = new ProjectRepository(db).create({
|
||||||
|
name: 'P',
|
||||||
|
ownerId: authorId,
|
||||||
|
}).id;
|
||||||
|
const members = new ProjectMemberRepository(db);
|
||||||
|
members.add(projectId, authorId, 'admin');
|
||||||
|
members.add(projectId, memberId, 'member');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
it('creates a meeting, invites members, notifies + logs activity, returns conflicts', () => {
|
||||||
|
const { meeting, conflicts } = service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'Kickoff',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
});
|
||||||
|
expect(meeting.title).toBe('Kickoff');
|
||||||
|
expect(conflicts).toEqual([]);
|
||||||
|
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1);
|
||||||
|
expect(
|
||||||
|
new ActivityLogRepository(db)
|
||||||
|
.findByProject(projectId)
|
||||||
|
.items.some((l) => l.action === 'meeting_created')
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
new MeetingRepository(db).findMembersByMeeting(meeting.id)
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkScheduleConflicts', () => {
|
||||||
|
it('detects an overlapping meeting the member attends', () => {
|
||||||
|
// 既存ミーティングに member が参加(10:00-11:00)
|
||||||
|
const existing = service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'existing',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
}).meeting;
|
||||||
|
// 新しいミーティング(10:30-11:30)は重複
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T10:30:00',
|
||||||
|
'2026-06-15T11:30:00'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
conflicts.some((c) => c.type === 'meeting' && c.refId === existing.id)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag a non-overlapping meeting', () => {
|
||||||
|
service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'existing',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
});
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T11:00:00',
|
||||||
|
'2026-06-15T12:00:00'
|
||||||
|
);
|
||||||
|
expect(conflicts.filter((c) => c.type === 'meeting')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the meeting itself via excludeMeetingId', () => {
|
||||||
|
const existing = service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'existing',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [memberId],
|
||||||
|
}).meeting;
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T10:30:00',
|
||||||
|
'2026-06-15T11:30:00',
|
||||||
|
existing.id
|
||||||
|
);
|
||||||
|
expect(conflicts.filter((c) => c.type === 'meeting')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects an overlapping calendar event created by the member', () => {
|
||||||
|
new CalendarRepository(db).create({
|
||||||
|
projectId,
|
||||||
|
title: 'member event',
|
||||||
|
type: 'custom',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
createdById: memberId,
|
||||||
|
});
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T10:30:00',
|
||||||
|
'2026-06-15T11:30:00'
|
||||||
|
);
|
||||||
|
expect(conflicts.some((c) => c.type === 'calendar_event')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a high-priority todo due within ±3 days', () => {
|
||||||
|
const col = new TodoRepository(db).createColumn({
|
||||||
|
projectId,
|
||||||
|
name: 'Todo',
|
||||||
|
orderIndex: 0,
|
||||||
|
}).id;
|
||||||
|
new TodoRepository(db).createItem({
|
||||||
|
projectId,
|
||||||
|
columnId: col,
|
||||||
|
title: 'urgent',
|
||||||
|
creatorId: authorId,
|
||||||
|
assigneeId: memberId,
|
||||||
|
priority: 'high',
|
||||||
|
dueDate: '2026-06-16',
|
||||||
|
orderIndex: 0,
|
||||||
|
});
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T10:00:00',
|
||||||
|
'2026-06-15T11:00:00'
|
||||||
|
);
|
||||||
|
expect(conflicts.some((c) => c.type === 'important_todo')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a high-priority todo due outside ±3 days', () => {
|
||||||
|
const col = new TodoRepository(db).createColumn({
|
||||||
|
projectId,
|
||||||
|
name: 'Todo',
|
||||||
|
orderIndex: 0,
|
||||||
|
}).id;
|
||||||
|
new TodoRepository(db).createItem({
|
||||||
|
projectId,
|
||||||
|
columnId: col,
|
||||||
|
title: 'far',
|
||||||
|
creatorId: authorId,
|
||||||
|
assigneeId: memberId,
|
||||||
|
priority: 'high',
|
||||||
|
dueDate: '2026-07-01',
|
||||||
|
orderIndex: 0,
|
||||||
|
});
|
||||||
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
projectId,
|
||||||
|
[memberId],
|
||||||
|
'2026-06-15T10:00:00',
|
||||||
|
'2026-06-15T11:00:00'
|
||||||
|
);
|
||||||
|
expect(conflicts.filter((c) => c.type === 'important_todo')).toHaveLength(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateMinutes sets minutes', () => {
|
||||||
|
const { meeting } = service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'M',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
service.updateMinutes(authorId, meeting.id, '# 議事録').minutesMd
|
||||||
|
).toBe('# 議事録');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete requires creator or admin', () => {
|
||||||
|
const { meeting } = service.createMeeting(authorId, projectId, {
|
||||||
|
title: 'M',
|
||||||
|
startAt: '2026-06-15T10:00:00',
|
||||||
|
endAt: '2026-06-15T11:00:00',
|
||||||
|
memberIds: [],
|
||||||
|
});
|
||||||
|
// member(非作成者・非管理者)は削除不可
|
||||||
|
expect(() => service.deleteMeeting(memberId, meeting.id)).toThrow(
|
||||||
|
ForbiddenError
|
||||||
|
);
|
||||||
|
service.deleteMeeting(authorId, meeting.id); // 作成者
|
||||||
|
expect(new MeetingRepository(db).findById(meeting.id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user