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:
Ken Yasue
2026-06-25 02:19:25 +02:00
parent 09e1e5e22a
commit 29e4bc6650
14 changed files with 1371 additions and 0 deletions

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