Files
opengroupware/services/MeetingService.ts
Ken Yasue f9720850b2 feat(m15): full test completion + quality gate + validation fixes
- Add E2E notifications.spec (member-added notification -> list -> mark read)
  and activity-log.spec (board_posted + todo_created recorded)
- Set Playwright workers=1 for deterministic full-suite runs (SSE test under
  parallel load); full E2E suite (18 tests / 13 specs incl. 12 mandated
  scenarios) green
- Quality gate: 251 unit/integration tests, lint clean, typecheck clean,
  build succeeds, coverage 91.13% Repository/Service (>=80%)
- Validation fixes (mandatory): SearchService now searches ALL items
  (large pageSize) instead of first page only; MeetingService.
  checkScheduleConflicts now requires membership (actorId) to prevent
  non-member info enumeration
2026-06-25 02:54:48 +02:00

294 lines
9.0 KiB
TypeScript

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(
actorId,
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(
actorId: number,
projectId: number,
memberIds: number[],
startAt: string,
endAt: string,
excludeMeetingId?: number
): ScheduleConflict[] {
this.requireMember(projectId, actorId);
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'
);
}
}
}