feat(m13): cross-resource search + dashboard completion, tests, e2e
- SearchService (cross-resource keyword search across board/chat/todo/file/ event/meeting/milestone/note with type filter, project isolation) - DashboardService (project dashboard: in-progress todos, near-due(7d), latest chat/board/notes/files(5), next meeting, milestones w/ progress, recent activity(10); personal dashboard: my projects, incomplete todos, upcoming meetings, unread notifications, overdue tasks, recent activity) - CalendarRepository.findByProject - APIs: GET /api/projects/:projectId/search, GET /api/dashboard - Screens: complete personal dashboard, complete project overview, search page + SearchForm, ProjectNav 検索 link - Unit tests (SearchService, DashboardService) + e2e dashboard
This commit is contained in:
155
services/DashboardService.ts
Normal file
155
services/DashboardService.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { ScheduleService } from '@/services/ScheduleService';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
import type {
|
||||
ActivityLog,
|
||||
BoardThread,
|
||||
ChatMessage,
|
||||
FileAsset,
|
||||
Meeting,
|
||||
Project,
|
||||
ProjectNote,
|
||||
TodoItem,
|
||||
} from '@/lib/types';
|
||||
|
||||
// MilestoneWithProgress is exported from ScheduleService; derive structurally
|
||||
type MilestoneWithProgress = ReturnType<
|
||||
ScheduleService['getMilestones']
|
||||
>[number];
|
||||
|
||||
export interface ProjectDashboard {
|
||||
project: Project;
|
||||
inProgressTodos: TodoItem[];
|
||||
nearDueTodos: TodoItem[];
|
||||
latestChat: ChatMessage[];
|
||||
latestBoard: BoardThread[];
|
||||
latestNotes: ProjectNote[];
|
||||
recentFiles: FileAsset[];
|
||||
nextMeeting: Meeting | null;
|
||||
milestones: MilestoneWithProgress[];
|
||||
recentActivity: ActivityLog[];
|
||||
}
|
||||
|
||||
export interface PersonalDashboard {
|
||||
projects: Project[];
|
||||
incompleteTodos: TodoItem[];
|
||||
upcomingMeetings: Meeting[];
|
||||
unreadNotificationCount: number;
|
||||
overdueTasks: TodoItem[];
|
||||
recentActivity: ActivityLog[];
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 個人/プロジェクトダッシュボードの集計を担うService。
|
||||
*/
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly todoRepository: TodoRepository,
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly boardRepository: BoardRepository,
|
||||
private readonly noteRepository: ProjectNoteRepository,
|
||||
private readonly fileRepository: FileRepository,
|
||||
private readonly meetingRepository: MeetingRepository,
|
||||
private readonly activityLogRepository: ActivityLogRepository,
|
||||
private readonly notificationRepository: NotificationRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly scheduleService: ScheduleService
|
||||
) {}
|
||||
|
||||
getProjectDashboard(actorId: number, projectId: number): ProjectDashboard {
|
||||
const project = this.projectRepository.findById(projectId);
|
||||
if (!project) throw new ForbiddenError('プロジェクトに参加していません');
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
}
|
||||
|
||||
const todos = this.todoRepository.findItemsByProject(projectId);
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const sevenDaysLater = new Date(now.getTime() + 7 * DAY_MS).toISOString();
|
||||
const todayPrefix = nowIso.slice(0, 10);
|
||||
|
||||
return {
|
||||
project,
|
||||
inProgressTodos: todos.filter((t) => t.completedAt === null),
|
||||
nearDueTodos: todos.filter(
|
||||
(t) =>
|
||||
t.completedAt === null &&
|
||||
t.dueDate !== null &&
|
||||
t.dueDate >= todayPrefix &&
|
||||
t.dueDate <= sevenDaysLater.slice(0, 10)
|
||||
),
|
||||
latestChat: this.chatRepository.findMessages(projectId, {
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
}).items,
|
||||
latestBoard: this.boardRepository.findThreads(projectId, {
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
}).items,
|
||||
latestNotes: this.noteRepository.findNotes(projectId, {
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
}).items,
|
||||
recentFiles: this.fileRepository.findFilesByProject(projectId, 1, 5)
|
||||
.items,
|
||||
nextMeeting:
|
||||
this.meetingRepository
|
||||
.findByProject(projectId)
|
||||
.filter((m) => m.startAt >= nowIso)
|
||||
.sort((a, b) => (a.startAt < b.startAt ? -1 : 1))[0] ?? null,
|
||||
milestones: this.scheduleService.getMilestones(actorId, projectId),
|
||||
recentActivity: this.activityLogRepository.findByProject(projectId, 1, 10)
|
||||
.items,
|
||||
};
|
||||
}
|
||||
|
||||
getPersonalDashboard(userId: number): PersonalDashboard {
|
||||
const projects = this.projectRepository.findProjectsByUserId(userId);
|
||||
const nowIso = new Date().toISOString();
|
||||
const todayPrefix = nowIso.slice(0, 10);
|
||||
const in30Days = new Date(Date.now() + 30 * DAY_MS).toISOString();
|
||||
|
||||
const incompleteTodos: TodoItem[] = [];
|
||||
const recentActivity: ActivityLog[] = [];
|
||||
for (const project of projects) {
|
||||
const todos = this.todoRepository
|
||||
.findItemsByProject(project.id)
|
||||
.filter((t) => t.assigneeId === userId && t.completedAt === null);
|
||||
incompleteTodos.push(...todos);
|
||||
recentActivity.push(
|
||||
...this.activityLogRepository.findByProject(project.id, 1, 10).items
|
||||
);
|
||||
}
|
||||
recentActivity.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
|
||||
const upcomingMeetings = this.meetingRepository
|
||||
.findMeetingsByUserInRange(userId, nowIso, in30Days)
|
||||
.sort((a, b) => (a.startAt < b.startAt ? -1 : 1))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
projects,
|
||||
incompleteTodos,
|
||||
upcomingMeetings,
|
||||
unreadNotificationCount:
|
||||
this.notificationRepository.countUnreadByUser(userId),
|
||||
overdueTasks: incompleteTodos.filter(
|
||||
(t) => t.dueDate !== null && t.dueDate < todayPrefix
|
||||
),
|
||||
recentActivity: recentActivity.slice(0, 10),
|
||||
};
|
||||
}
|
||||
}
|
||||
178
services/SearchService.ts
Normal file
178
services/SearchService.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
export type SearchResourceType =
|
||||
| 'thread'
|
||||
| 'chat'
|
||||
| 'todo'
|
||||
| 'file'
|
||||
| 'event'
|
||||
| 'meeting'
|
||||
| 'milestone'
|
||||
| 'note';
|
||||
|
||||
export interface SearchResult {
|
||||
type: SearchResourceType;
|
||||
id: number;
|
||||
title: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
q: string;
|
||||
type?: SearchResourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* プロジェクト内の横断検索を担うService。
|
||||
* 各リソースを取得しキーワードで絞り込む(小規模データ前提)。
|
||||
*/
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly boardRepository: BoardRepository,
|
||||
private readonly chatRepository: ChatRepository,
|
||||
private readonly todoRepository: TodoRepository,
|
||||
private readonly fileRepository: FileRepository,
|
||||
private readonly calendarRepository: CalendarRepository,
|
||||
private readonly meetingRepository: MeetingRepository,
|
||||
private readonly milestoneRepository: MilestoneRepository,
|
||||
private readonly noteRepository: ProjectNoteRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository
|
||||
) {}
|
||||
|
||||
search(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
opts: SearchOptions
|
||||
): SearchResult[] {
|
||||
this.requireMember(projectId, actorId);
|
||||
const q = opts.q.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const type = opts.type;
|
||||
const results: SearchResult[] = [];
|
||||
const match = (text: string | null | undefined) =>
|
||||
!!text && text.toLowerCase().includes(q);
|
||||
|
||||
if (!type || type === 'thread') {
|
||||
for (const t of this.boardRepository.findThreads(projectId).items) {
|
||||
if (match(t.title) || match(t.bodyMd)) {
|
||||
results.push({
|
||||
type: 'thread',
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
snippet: t.bodyMd.slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'chat') {
|
||||
for (const m of this.chatRepository.findMessages(projectId).items) {
|
||||
if (match(m.body)) {
|
||||
results.push({
|
||||
type: 'chat',
|
||||
id: m.id,
|
||||
title: m.body.slice(0, 40),
|
||||
snippet: m.body.slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'todo') {
|
||||
for (const t of this.todoRepository.findItemsByProject(projectId)) {
|
||||
if (match(t.title) || match(t.description)) {
|
||||
results.push({
|
||||
type: 'todo',
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
snippet: t.description?.slice(0, 80) ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'file') {
|
||||
const files = this.fileRepository.findFilesByProject(
|
||||
projectId,
|
||||
1,
|
||||
500
|
||||
).items;
|
||||
for (const f of files) {
|
||||
if (match(f.originalName)) {
|
||||
results.push({
|
||||
type: 'file',
|
||||
id: f.id,
|
||||
title: f.originalName,
|
||||
snippet: f.mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'event') {
|
||||
for (const e of this.calendarRepository.findByProject(projectId)) {
|
||||
if (match(e.title) || match(e.description)) {
|
||||
results.push({
|
||||
type: 'event',
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
snippet: e.description?.slice(0, 80) ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'meeting') {
|
||||
for (const m of this.meetingRepository.findByProject(projectId)) {
|
||||
if (
|
||||
match(m.title) ||
|
||||
match(m.description) ||
|
||||
match(m.agendaMd) ||
|
||||
match(m.minutesMd)
|
||||
) {
|
||||
results.push({
|
||||
type: 'meeting',
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
snippet: m.description?.slice(0, 80) ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'milestone') {
|
||||
for (const m of this.milestoneRepository.findByProject(projectId)) {
|
||||
if (match(m.title) || match(m.description)) {
|
||||
results.push({
|
||||
type: 'milestone',
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
snippet: m.description?.slice(0, 80) ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!type || type === 'note') {
|
||||
for (const n of this.noteRepository.findNotes(projectId).items) {
|
||||
if (match(n.title) || match(n.bodyMd) || match(n.tags)) {
|
||||
results.push({
|
||||
type: 'note',
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
snippet: n.bodyMd.slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user