diff --git a/app/api/projects/[projectId]/meetings/[id]/route.ts b/app/api/projects/[projectId]/meetings/[id]/route.ts new file mode 100644 index 0000000..ce7859c --- /dev/null +++ b/app/api/projects/[projectId]/meetings/[id]/route.ts @@ -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; + try { + body = (await request.json()) as Record; + } 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; + try { + body = (await request.json()) as Record; + } 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); + } +} diff --git a/app/api/projects/[projectId]/meetings/route.ts b/app/api/projects/[projectId]/meetings/route.ts new file mode 100644 index 0000000..574e480 --- /dev/null +++ b/app/api/projects/[projectId]/meetings/route.ts @@ -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; + try { + body = (await request.json()) as Record; + } 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); + } +} diff --git a/app/projects/[projectId]/meetings/page.tsx b/app/projects/[projectId]/meetings/page.tsx new file mode 100644 index 0000000..8d996cc --- /dev/null +++ b/app/projects/[projectId]/meetings/page.tsx @@ -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>; + 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 ( +
+
+ +
+

ミーティング

+ +
+ {meetings.length === 0 ? ( +

ミーティングはありません。

+ ) : ( + meetings.map((m) => ( +
+

{m.title}

+

+ {m.startAt} 〜 {m.endAt} +

+ {m.location && ( +

場所: {m.location}

+ )} + {m.minutesMd && ( +

議事録あり

+ )} +
+ )) + )} +
+
+
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index 2238723..5e26aae 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -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', diff --git a/components/meetings/ConflictWarning.tsx b/components/meetings/ConflictWarning.tsx new file mode 100644 index 0000000..ac68f22 --- /dev/null +++ b/components/meetings/ConflictWarning.tsx @@ -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 = { + meeting: 'ミーティング', + calendar_event: 'カレンダーイベント', + important_todo: '期限の近い重要タスク', +}; + +export function ConflictWarning({ + conflicts, +}: { + conflicts: ScheduleConflict[]; +}) { + if (conflicts.length === 0) { + return ( +

+ スケジュールの重複はありません。 +

+ ); + } + return ( +
+

+ スケジュールの重複があります(作成は完了しています): +

+
    + {conflicts.map((c, i) => ( +
  • + [{TYPE_LABELS[c.type] ?? c.type}] {c.title}({c.startAt} + {c.endAt ? ` 〜 ${c.endAt}` : ''}) +
  • + ))} +
+
+ ); +} diff --git a/components/meetings/MeetingForm.tsx b/components/meetings/MeetingForm.tsx new file mode 100644 index 0000000..152cbee --- /dev/null +++ b/components/meetings/MeetingForm.tsx @@ -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([]); + const [conflicts, setConflicts] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onSubmit(event: FormEvent) { + 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 ( +
+
+ + setTitle(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + data-testid="meeting-title" + /> +
+
+
+ + setStartAt(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + data-testid="meeting-start" + /> +
+
+ + setEndAt(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + required + data-testid="meeting-end" + /> +
+
+
+ +
+ {members.map((m) => ( + + ))} +
+
+ {error && ( +

+ {error} +

+ )} + + {conflicts && } + + ); +} diff --git a/lib/api/services.ts b/lib/api/services.ts index 7a58e34..295b946 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -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()); } diff --git a/repositories/CalendarRepository.ts b/repositories/CalendarRepository.ts index 2a81ef6..698b1d9 100644 --- a/repositories/CalendarRepository.ts +++ b/repositories/CalendarRepository.ts @@ -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( + `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( 'SELECT * FROM calendar_events WHERE id = @id AND deleted_at IS NULL', diff --git a/repositories/MeetingRepository.ts b/repositories/MeetingRepository.ts new file mode 100644 index 0000000..44663cb --- /dev/null +++ b/repositories/MeetingRepository.ts @@ -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( + '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( + '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 = { + 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( + '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( + `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; + } +} diff --git a/repositories/TodoRepository.ts b/repositories/TodoRepository.ts index c455ce1..bc5a6bf 100644 --- a/repositories/TodoRepository.ts +++ b/repositories/TodoRepository.ts @@ -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( + `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); + } } diff --git a/services/MeetingService.ts b/services/MeetingService.ts new file mode 100644 index 0000000..273ae87 --- /dev/null +++ b/services/MeetingService.ts @@ -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 + ): 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' + ); + } + } +} diff --git a/tests/e2e/meetings.spec.ts b/tests/e2e/meetings.spec.ts new file mode 100644 index 0000000..c58f57a --- /dev/null +++ b/tests/e2e/meetings.spec.ts @@ -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(); + }); +}); diff --git a/tests/unit/repositories/MeetingRepository.test.ts b/tests/unit/repositories/MeetingRepository.test.ts new file mode 100644 index 0000000..a2ef4a1 --- /dev/null +++ b/tests/unit/repositories/MeetingRepository.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/MeetingService.test.ts b/tests/unit/services/MeetingService.test.ts new file mode 100644 index 0000000..7c01f9a --- /dev/null +++ b/tests/unit/services/MeetingService.test.ts @@ -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(); + }); +});