Files
opengroupware/services/ScheduleService.ts
Ken Yasue 31d8ad1325 feat(m11): calendar + milestones with progress calc, tests, e2e
- CalendarRepository (event CRUD, range query, soft-delete, project isolation)
  + MilestoneRepository (CRUD, findToDosByMilestone) + ScheduleService
  (aggregated getCalendarEvents = custom events+milestones+todos in range,
  milestone CRUD, milestone_updated activity, progress auto-calc 0-100)
- APIs: calendar events GET/POST/PATCH/DELETE, milestones GET/POST/PATCH +
  progress
- Screens: calendar list view + CalendarEventForm, milestones list with
  progress bars + MilestoneForm, ProjectNav links
- Unit tests (CalendarRepository, MilestoneRepository, ScheduleService incl.
  progress calc 0/33/100) + e2e calendar
2026-06-25 02:11:10 +02:00

248 lines
7.8 KiB
TypeScript

import {
CalendarRepository,
type CreateEventInput,
} from '@/repositories/CalendarRepository';
import {
MilestoneRepository,
type CreateMilestoneInput,
} from '@/repositories/MilestoneRepository';
import { TodoRepository } from '@/repositories/TodoRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ActivityLogService } from '@/services/ActivityLogService';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type {
CalendarEvent,
CalendarEventType,
Milestone,
MilestoneStatus,
} from '@/lib/types';
/** カレンダー集約ビュー(複数ソースを統一表示) */
export interface CalendarEventView {
key: string;
projectId: number;
title: string;
description: string | null;
type: CalendarEventType;
startAt: string;
endAt: string | null;
source: 'event' | 'milestone' | 'todo';
refId: number;
}
export interface MilestoneWithProgress extends Milestone {
progress: number;
}
export interface UpdateMilestoneInput {
title?: string;
description?: string | null;
dueDate?: string | null;
status?: MilestoneStatus;
}
/**
* カレンダーイベント集約とマイルストーン管理を担うService。
* カレンダーイベントの集計(カスタムイベント+マイルストーン+ToDo)と
* マイルストーン進捗率自動計算を行う。
*/
export class ScheduleService {
constructor(
private readonly calendarRepository: CalendarRepository,
private readonly milestoneRepository: MilestoneRepository,
private readonly todoRepository: TodoRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly activityLogService: ActivityLogService
) {}
getCalendarEvents(
actorId: number,
projectId: number,
range: { from: string; to: string }
): CalendarEventView[] {
this.requireMember(projectId, actorId);
const views: CalendarEventView[] = [];
for (const e of this.calendarRepository.findByProjectInRange(
projectId,
range.from,
range.to
)) {
views.push({
key: `event-${e.id}`,
projectId,
title: e.title,
description: e.description,
type: e.type,
startAt: e.startAt,
endAt: e.endAt,
source: 'event',
refId: e.id,
});
}
for (const m of this.milestoneRepository.findByProject(projectId)) {
if (!m.dueDate) continue;
if (m.dueDate >= range.from && m.dueDate <= range.to) {
views.push({
key: `milestone-${m.id}`,
projectId,
title: `マイルストーン: ${m.title}`,
description: m.description,
type: 'milestone',
startAt: m.dueDate,
endAt: m.dueDate,
source: 'milestone',
refId: m.id,
});
}
}
for (const t of this.todoRepository.findItemsByProject(projectId)) {
const start = t.dueDate ?? t.startDate;
if (!start) continue;
if (start >= range.from && start <= range.to) {
views.push({
key: `todo-${t.id}`,
projectId,
title: `ToDo: ${t.title}`,
description: t.description,
type: 'todo',
startAt: start,
endAt: t.dueDate ?? null,
source: 'todo',
refId: t.id,
});
}
}
return views.sort((a, b) =>
a.startAt < b.startAt ? -1 : a.startAt > b.startAt ? 1 : 0
);
}
// ----- calendar events -----
createEvent(
actorId: number,
input: Omit<CreateEventInput, 'projectId' | 'createdById'> & {
projectId: number;
}
): CalendarEvent {
this.requireMember(input.projectId, actorId);
if (!input.title.trim()) {
throw new ValidationError('タイトルを入力してください', 'title');
}
return this.calendarRepository.create({
projectId: input.projectId,
title: input.title,
description: input.description ?? null,
type: input.type,
startAt: input.startAt,
endAt: input.endAt ?? null,
createdById: actorId,
});
}
updateEvent(
actorId: number,
eventId: number,
input: {
title?: string;
description?: string | null;
startAt?: string;
endAt?: string | null;
}
): CalendarEvent {
const event = this.calendarRepository.findEventById(eventId);
if (!event) throw new NotFoundError('CalendarEvent', eventId);
this.requireMember(event.projectId, actorId);
const updated = this.calendarRepository.update(eventId, input);
if (!updated) throw new NotFoundError('CalendarEvent', eventId);
return updated;
}
deleteEvent(actorId: number, eventId: number): void {
const event = this.calendarRepository.findEventById(eventId);
if (!event) throw new NotFoundError('CalendarEvent', eventId);
this.requireMember(event.projectId, actorId);
const role = this.projectMemberRepository.getRole(event.projectId, actorId);
if (event.createdById !== actorId && role !== 'admin') {
throw new ForbiddenError('作成者または管理者のみ削除できます');
}
this.calendarRepository.delete(eventId);
}
// ----- milestones -----
getMilestones(actorId: number, projectId: number): MilestoneWithProgress[] {
this.requireMember(projectId, actorId);
return this.milestoneRepository.findByProject(projectId).map((m) => ({
...m,
progress: this.calcMilestoneProgress(m.id),
}));
}
createMilestone(
actorId: number,
projectId: number,
input: Omit<CreateMilestoneInput, 'projectId'>
): Milestone {
this.requireMember(projectId, actorId);
if (!input.title.trim()) {
throw new ValidationError('タイトルを入力してください', 'title');
}
return this.milestoneRepository.create({ ...input, projectId });
}
updateMilestone(
actorId: number,
milestoneId: number,
input: UpdateMilestoneInput
): Milestone {
const milestone = this.milestoneRepository.findById(milestoneId);
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
this.requireMember(milestone.projectId, actorId);
const updated = this.milestoneRepository.update(milestoneId, input);
if (!updated) throw new NotFoundError('Milestone', milestoneId);
this.activityLogService.logActivity({
projectId: milestone.projectId,
actorId,
action: 'milestone_updated',
targetType: 'milestone',
targetId: milestoneId,
});
return updated;
}
deleteMilestone(actorId: number, milestoneId: number): void {
const milestone = this.milestoneRepository.findById(milestoneId);
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
this.requireMember(milestone.projectId, actorId);
const role = this.projectMemberRepository.getRole(
milestone.projectId,
actorId
);
if (role !== 'admin') {
throw new ForbiddenError('管理者のみ削除できます');
}
this.milestoneRepository.delete(milestoneId);
}
getMilestoneProgress(actorId: number, milestoneId: number): number {
const milestone = this.milestoneRepository.findById(milestoneId);
if (!milestone) throw new NotFoundError('Milestone', milestoneId);
this.requireMember(milestone.projectId, actorId);
return this.calcMilestoneProgress(milestoneId);
}
/** 進捗率: 関連ToDoの完了率(0件時0%) */
calcMilestoneProgress(milestoneId: number): number {
const todos = this.milestoneRepository.findToDosByMilestone(milestoneId);
if (todos.length === 0) return 0;
const completed = todos.filter((t) => t.completedAt !== null).length;
return Math.round((completed / todos.length) * 100);
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
}
}