- 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
179 lines
5.2 KiB
TypeScript
179 lines
5.2 KiB
TypeScript
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('プロジェクトに参加していません');
|
|
}
|
|
}
|
|
}
|