Files
opengroupware/services/SearchService.ts
Ken Yasue f9720850b2 feat(m15): full test completion + quality gate + validation fixes
- Add E2E notifications.spec (member-added notification -> list -> mark read)
  and activity-log.spec (board_posted + todo_created recorded)
- Set Playwright workers=1 for deterministic full-suite runs (SSE test under
  parallel load); full E2E suite (18 tests / 13 specs incl. 12 mandated
  scenarios) green
- Quality gate: 251 unit/integration tests, lint clean, typecheck clean,
  build succeeds, coverage 91.13% Repository/Service (>=80%)
- Validation fixes (mandatory): SearchService now searches ALL items
  (large pageSize) instead of first page only; MeetingService.
  checkScheduleConflicts now requires membership (actorId) to prevent
  non-member info enumeration
2026-06-25 02:54:48 +02:00

191 lines
5.6 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;
}
// 検索は全件対象とする(ページネーション既定値による検索漏れを防ぐ)
const SEARCH_MAX = Number.MAX_SAFE_INTEGER;
/**
* プロジェクト内の横断検索を担う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, {
page: 1,
pageSize: SEARCH_MAX,
}).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, {
page: 1,
pageSize: SEARCH_MAX,
}).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,
SEARCH_MAX
).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, {
page: 1,
pageSize: SEARCH_MAX,
}).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('プロジェクトに参加していません');
}
}
}