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

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