Merge branch 'feature/m12-meetings' - M12 meetings & schedule conflict

This commit is contained in:
Ken Yasue
2026-06-25 02:19:29 +02:00
14 changed files with 1371 additions and 0 deletions

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

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

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

View File

@ -7,6 +7,7 @@ const NAV_ITEMS = [
{ href: '/files', label: 'ファイル' },
{ href: '/calendar', label: 'カレンダー' },
{ href: '/milestones', label: 'マイルストーン' },
{ href: '/meetings', label: 'ミーティング' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
@ -30,6 +31,7 @@ export function ProjectNav({
| 'files'
| 'calendar'
| 'milestones'
| 'meetings'
| 'members'
| 'activity'
| 'settings';
@ -43,6 +45,7 @@ export function ProjectNav({
files: active === 'files',
calendar: active === 'calendar',
milestones: active === 'milestones',
meetings: active === 'meetings',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',

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

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

View File

@ -21,6 +21,8 @@ import { FileRepository } from '@/repositories/FileRepository';
import { ScheduleService } from '@/services/ScheduleService';
import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
import { MeetingService } from '@/services/MeetingService';
import { MeetingRepository } from '@/repositories/MeetingRepository';
/**
* 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 {
return new UserRepository(getDb());
}

View File

@ -65,6 +65,23 @@ export class CalendarRepository {
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 {
const row = this.db.get<CalendarEventRow>(
'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL',

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

View File

@ -271,4 +271,21 @@ export class TodoRepository {
);
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
View 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'
);
}
}
}

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

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

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