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
This commit is contained in:
247
services/ScheduleService.ts
Normal file
247
services/ScheduleService.ts
Normal file
@ -0,0 +1,247 @@
|
||||
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('プロジェクトに参加していません');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user