From 755c242d2be7b052937e030bdbe6d20ff246a1b7 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 02:28:17 +0200 Subject: [PATCH] feat(m13): cross-resource search + dashboard completion, tests, e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/dashboard/route.ts | 19 ++ app/api/projects/[projectId]/search/route.ts | 32 ++++ app/dashboard/page.tsx | 84 ++++++--- app/projects/[projectId]/page.tsx | 133 ++++++++++---- app/projects/[projectId]/search/page.tsx | 93 ++++++++++ components/layout/ProjectNav.tsx | 3 + components/project/SearchForm.tsx | 72 ++++++++ lib/api/services.ts | 42 +++++ repositories/CalendarRepository.ts | 8 + services/DashboardService.ts | 155 ++++++++++++++++ services/SearchService.ts | 178 ++++++++++++++++++ tests/e2e/dashboard.spec.ts | 85 +++++++++ tests/unit/services/DashboardService.test.ts | 180 +++++++++++++++++++ tests/unit/services/SearchService.test.ts | 161 +++++++++++++++++ 14 files changed, 1192 insertions(+), 53 deletions(-) create mode 100644 app/api/dashboard/route.ts create mode 100644 app/api/projects/[projectId]/search/route.ts create mode 100644 app/projects/[projectId]/search/page.tsx create mode 100644 components/project/SearchForm.tsx create mode 100644 services/DashboardService.ts create mode 100644 services/SearchService.ts create mode 100644 tests/e2e/dashboard.spec.ts create mode 100644 tests/unit/services/DashboardService.test.ts create mode 100644 tests/unit/services/SearchService.test.ts diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..eebadfa --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createDashboardService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET() { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + + const service = createDashboardService(); + try { + return NextResponse.json(service.getPersonalDashboard(user.id)); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/search/route.ts b/app/api/projects/[projectId]/search/route.ts new file mode 100644 index 0000000..6036d77 --- /dev/null +++ b/app/api/projects/[projectId]/search/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createSearchService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const q = request.nextUrl.searchParams.get('q') ?? ''; + const type = (request.nextUrl.searchParams.get('type') ?? undefined) as + | string + | undefined; + + const service = createSearchService(); + try { + return NextResponse.json( + service.search(user.id, Number(projectId), { + q, + type: type as never, + }) + ); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 253da87..7e2d6db 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,9 +1,9 @@ import { redirect } from 'next/navigation'; import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; -import { createProjectService } from '@/lib/api/services'; +import { createDashboardService } from '@/lib/api/services'; import { Header } from '@/components/layout/Header'; -import { ProjectCard } from '@/components/project/ProjectCard'; import { CreateProjectForm } from '@/components/project/CreateProjectForm'; +import { DashboardWidget } from '@/components/project/DashboardWidget'; export const dynamic = 'force-dynamic'; @@ -13,31 +13,75 @@ export default async function DashboardPage() { redirect('/login'); } - const service = createProjectService(); - const projects = service.getMyProjects(user.id); + const service = createDashboardService(); + const dashboard = service.getPersonalDashboard(user.id); return (

ダッシュボード

+ +
+ + {dashboard.projects.map((p) => ( + + {p.name}({p.status}) + + ))} + + + + + 通知一覧を開く + + + + + {dashboard.incompleteTodos.map((t) => ( +

+ {t.title} + {t.dueDate ? `(期限: ${t.dueDate})` : ''} +

+ ))} +
+ + + {dashboard.overdueTasks.map((t) => ( +

+ {t.title}(期限: {t.dueDate}) +

+ ))} +
+ + + {dashboard.upcomingMeetings.map((m) => ( +

+ {m.title}({m.startAt}) +

+ ))} +
+ + + {dashboard.recentActivity.map((l) => ( +

+ {l.action}({l.targetType}) +

+ ))} +
+
+ -
-

- 参加プロジェクト -

- {projects.length === 0 ? ( -

- 参加しているプロジェクトはありません。 -

- ) : ( -
- {projects.map((project) => ( - - ))} -
- )} -
); diff --git a/app/projects/[projectId]/page.tsx b/app/projects/[projectId]/page.tsx index 9a0d3d2..192c801 100644 --- a/app/projects/[projectId]/page.tsx +++ b/app/projects/[projectId]/page.tsx @@ -1,43 +1,34 @@ import { redirect } from 'next/navigation'; import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; -import { createProjectService } from '@/lib/api/services'; +import { createDashboardService } from '@/lib/api/services'; import { Header } from '@/components/layout/Header'; import { ProjectNav } from '@/components/layout/ProjectNav'; import { DashboardWidget } from '@/components/project/DashboardWidget'; -import { ForbiddenError, NotFoundError } from '@/lib/errors'; +import { ForbiddenError } from '@/lib/errors'; export const dynamic = 'force-dynamic'; -const STATUS_LABELS: Record = { - active: 'Active', - on_hold: 'On Hold', - completed: 'Completed', - archived: 'Archived', -}; - export default async function ProjectOverviewPage({ params, }: { params: Promise<{ projectId: string }>; }) { const user = await getCurrentUser(); - if (!user) { - redirect('/login'); - } + if (!user) redirect('/login'); const { projectId } = await params; - const service = createProjectService(); + const service = createDashboardService(); let dashboard; try { - dashboard = service.getDashboard(user.id, Number(projectId)); + dashboard = service.getProjectDashboard(user.id, Number(projectId)); } catch (error) { - if (error instanceof ForbiddenError || error instanceof NotFoundError) { + if (error instanceof ForbiddenError) { redirect('/dashboard'); } throw error; } - const { project, members } = dashboard; + const { project } = dashboard; return (
@@ -47,31 +38,107 @@ export default async function ProjectOverviewPage({

{project.name}

- {STATUS_LABELS[project.status] ?? project.status} + {project.status}
{project.description && (

{project.description}

)} -

メンバー数: {members.length}

+ + {dashboard.inProgressTodos.map((t) => ( +

+ {t.title} + {t.dueDate ? `(期限: ${t.dueDate})` : ''} +

+ ))} +
+ + + {dashboard.nearDueTodos.map((t) => ( +

+ {t.title}({t.dueDate}) +

+ ))} +
+ + + {dashboard.latestChat.map((m) => ( +

+ {m.body} +

+ ))} +
+ + + {dashboard.latestBoard.map((t) => ( + + {t.isPinned === 1 ? '📌 ' : ''} + {t.title} + + ))} + + + + {dashboard.latestNotes.map((n) => ( + + {n.isPinned === 1 ? '📌 ' : ''} + {n.title} + + ))} + + + + {dashboard.recentFiles.map((f) => ( +

+ {f.originalName} +

+ ))} +
+ + + {dashboard.nextMeeting && ( +

+ {dashboard.nextMeeting.title}({dashboard.nextMeeting.startAt}) +

+ )} +
+ + + {dashboard.milestones.map((m) => ( +
+

+ {m.title} - {m.progress}% +

+
+
+
+
+ ))} + + - - - + title="最近のアクティビティ (10件)" + empty="ありません" + > + {dashboard.recentActivity.map((l) => ( +

+ {l.action}({l.targetType}) +

+ ))} +
diff --git a/app/projects/[projectId]/search/page.tsx b/app/projects/[projectId]/search/page.tsx new file mode 100644 index 0000000..e8e213f --- /dev/null +++ b/app/projects/[projectId]/search/page.tsx @@ -0,0 +1,93 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createSearchService, createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { SearchForm } from '@/components/project/SearchForm'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +const TYPE_LINKS: Record string> = { + thread: (id, p) => `/projects/${p}/board/${id}`, + note: (id, p) => `/projects/${p}/notes/${id}`, + todo: (_id, p) => `/projects/${p}/todos`, + file: (id) => `/api/files/${id}/download`, + event: (_id, p) => `/projects/${p}/calendar`, + meeting: (_id, p) => `/projects/${p}/meetings`, + milestone: (_id, p) => `/projects/${p}/milestones`, + chat: (_id, p) => `/projects/${p}/chat`, +}; + +export default async function SearchPage({ + params, + searchParams, +}: { + params: Promise<{ projectId: string }>; + searchParams: Promise<{ q?: string; type?: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + const { q, type } = await searchParams; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const searchService = createSearchService(); + const results = q + ? searchService.search(user.id, project.id, { + q, + type: type as never, + }) + : []; + + return ( +
+
+ +
+

検索

+ +
+ {q && results.length === 0 && ( +

該当する結果はありません。

+ )} + {results.map((r) => { + const href = + TYPE_LINKS[r.type]?.(r.id, project.id) ?? + `/projects/${project.id}`; + return ( + +
+ {r.title} + + {r.type} + +
+

{r.snippet}

+
+ ); + })} +
+
+
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index 5e26aae..d2383d5 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -8,6 +8,7 @@ const NAV_ITEMS = [ { href: '/calendar', label: 'カレンダー' }, { href: '/milestones', label: 'マイルストーン' }, { href: '/meetings', label: 'ミーティング' }, + { href: '/search', label: '検索' }, { href: '/members', label: 'メンバー' }, { href: '/activity', label: 'アクティビティ' }, { href: '/settings', label: '設定' }, @@ -32,6 +33,7 @@ export function ProjectNav({ | 'calendar' | 'milestones' | 'meetings' + | 'search' | 'members' | 'activity' | 'settings'; @@ -46,6 +48,7 @@ export function ProjectNav({ calendar: active === 'calendar', milestones: active === 'milestones', meetings: active === 'meetings', + search: active === 'search', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/components/project/SearchForm.tsx b/components/project/SearchForm.tsx new file mode 100644 index 0000000..62fe8b4 --- /dev/null +++ b/components/project/SearchForm.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; + +const TYPES = [ + { value: '', label: 'すべて' }, + { value: 'thread', label: '掲示板' }, + { value: 'chat', label: 'チャット' }, + { value: 'todo', label: 'ToDo' }, + { value: 'file', label: 'ファイル' }, + { value: 'event', label: 'イベント' }, + { value: 'meeting', label: 'ミーティング' }, + { value: 'milestone', label: 'マイルストーン' }, + { value: 'note', label: 'メモ' }, +]; + +export function SearchForm({ + projectId, + initialQ, + initialType, +}: { + projectId: number; + initialQ: string; + initialType: string; +}) { + const router = useRouter(); + const [q, setQ] = useState(initialQ); + const [type, setType] = useState(initialType); + + function onSubmit(event: FormEvent) { + event.preventDefault(); + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (type) params.set('type', type); + router.push(`/projects/${projectId}/search?${params.toString()}`); + } + + return ( +
+ setQ(e.target.value)} + placeholder="キーワード" + className="flex-1 rounded border px-3 py-2" + data-testid="search-input" + /> + + +
+ ); +} diff --git a/lib/api/services.ts b/lib/api/services.ts index 295b946..9e6489a 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -23,6 +23,8 @@ import { CalendarRepository } from '@/repositories/CalendarRepository'; import { MilestoneRepository } from '@/repositories/MilestoneRepository'; import { MeetingService } from '@/services/MeetingService'; import { MeetingRepository } from '@/repositories/MeetingRepository'; +import { SearchService } from '@/services/SearchService'; +import { DashboardService } from '@/services/DashboardService'; /** * Route Handler用に各Repository/Serviceを構築するファクトリ。 @@ -126,6 +128,46 @@ export function createMeetingService(): MeetingService { ); } +export function createSearchService(): SearchService { + const db = getDb(); + return new SearchService( + new BoardRepository(db), + new ChatRepository(db), + new TodoRepository(db), + new FileRepository(db), + new CalendarRepository(db), + new MeetingRepository(db), + new MilestoneRepository(db), + new ProjectNoteRepository(db), + new ProjectMemberRepository(db) + ); +} + +export function createDashboardService(): DashboardService { + const db = getDb(); + const memberRepo = new ProjectMemberRepository(db); + const scheduleService = new ScheduleService( + new CalendarRepository(db), + new MilestoneRepository(db), + new TodoRepository(db), + memberRepo, + new ActivityLogService(new ActivityLogRepository(db)) + ); + return new DashboardService( + new ProjectRepository(db), + new TodoRepository(db), + new ChatRepository(db), + new BoardRepository(db), + new ProjectNoteRepository(db), + new FileRepository(db), + new MeetingRepository(db), + new ActivityLogRepository(db), + new NotificationRepository(db), + memberRepo, + scheduleService + ); +} + export function createUserRepository(): UserRepository { return new UserRepository(getDb()); } diff --git a/repositories/CalendarRepository.ts b/repositories/CalendarRepository.ts index 698b1d9..82e8d9e 100644 --- a/repositories/CalendarRepository.ts +++ b/repositories/CalendarRepository.ts @@ -50,6 +50,14 @@ export interface CreateEventInput { export class CalendarRepository { constructor(private readonly db: SqliteDatabase) {} + findByProject(projectId: number): CalendarEvent[] { + const rows = this.db.query( + 'SELECT * FROM calendar_events WHERE project_id = @projectId AND deleted_at IS NULL ORDER BY start_at ASC, id ASC', + { projectId } + ); + return rows.map(mapEvent); + } + findByProjectInRange( projectId: number, from: string, diff --git a/services/DashboardService.ts b/services/DashboardService.ts new file mode 100644 index 0000000..bb75d13 --- /dev/null +++ b/services/DashboardService.ts @@ -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), + }; + } +} diff --git a/services/SearchService.ts b/services/SearchService.ts new file mode 100644 index 0000000..206fca5 --- /dev/null +++ b/services/SearchService.ts @@ -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('プロジェクトに参加していません'); + } + } +} diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..203d80d --- /dev/null +++ b/tests/e2e/dashboard.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function setupOwner(page: import('@playwright/test').Page) { + const email = unique('owner') + '@example.com'; + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill('Owner'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + await expect(page).toHaveURL(/\/dashboard/); + await page.getByLabel('プロジェクト名').fill(unique('Proj')); + await page.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(page).toHaveURL(/\/projects\/\d+$/); + return Number(page.url().match(/\/projects\/(\d+)/)![1]); +} + +test.describe('dashboard & search', () => { + test('personal dashboard, project dashboard, and search', async ({ + page, + }) => { + const projectId = await setupOwner(page); + const keyword = unique('unicorn'); + + // データ作成: 掲示板スレッド + ToDo + await page.request.post(`/api/projects/${projectId}/board/threads`, { + data: { title: `thread ${keyword}`, bodyMd: 'body', category: 'notice' }, + }); + const cols = ( + (await ( + await page.request.get(`/api/projects/${projectId}/todos/columns`) + ).json()) as { + columns: { id: number }[]; + } + ).columns; + await page.request.post(`/api/projects/${projectId}/todos/items`, { + data: { + title: `task ${keyword}`, + columnId: cols[0].id, + assigneeId: undefined, + }, + }); + // 自分(owner)のIDを取得してToDo担当にする + const me = (await (await page.request.get('/api/auth/me')).json()) as { + user: { id: number }; + }; + const items = ( + (await ( + await page.request.get(`/api/projects/${projectId}/todos/items`) + ).json()) as { + items: { id: number; title: string }[]; + } + ).items; + const taskId = items.find((i) => i.title.includes(keyword))!.id; + await page.request.patch( + `/api/projects/${projectId}/todos/items/${taskId}`, + { + data: { assigneeId: me.user.id }, + } + ); + + // 個人ダッシュボード: プロジェクト + 未完了ToDo + await page.goto('/dashboard'); + await expect(page.getByText(unique('Proj').split('-')[0])).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText(`task ${keyword}`)).toBeVisible(); + + // プロジェクトダッシュボード: 進行中ToDo + 最新掲示板 + await page.goto(`/projects/${projectId}`); + await expect(page.getByText(`task ${keyword}`)).toBeVisible(); + await expect(page.getByText(`thread ${keyword}`)).toBeVisible(); + + // 検索 + await page.goto( + `/projects/${projectId}/search?q=${encodeURIComponent(keyword)}` + ); + await expect(page.getByText(`thread ${keyword}`)).toBeVisible(); + await expect(page.getByText(`task ${keyword}`)).toBeVisible(); + }); +}); diff --git a/tests/unit/services/DashboardService.test.ts b/tests/unit/services/DashboardService.test.ts new file mode 100644 index 0000000..a452b37 --- /dev/null +++ b/tests/unit/services/DashboardService.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +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 { CalendarRepository } from '@/repositories/CalendarRepository'; +import { MilestoneRepository } from '@/repositories/MilestoneRepository'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { ScheduleService } from '@/services/ScheduleService'; +import { DashboardService } from '@/services/DashboardService'; +import { ForbiddenError } from '@/lib/errors'; + +function makeServices(db: SqliteDatabase) { + const memberRepo = new ProjectMemberRepository(db); + const scheduleService = new ScheduleService( + new CalendarRepository(db), + new MilestoneRepository(db), + new TodoRepository(db), + memberRepo, + new ActivityLogService(new ActivityLogRepository(db)) + ); + const dashboard = new DashboardService( + new ProjectRepository(db), + new TodoRepository(db), + new ChatRepository(db), + new BoardRepository(db), + new ProjectNoteRepository(db), + new FileRepository(db), + new MeetingRepository(db), + new ActivityLogRepository(db), + new NotificationRepository(db), + memberRepo, + scheduleService + ); + return { dashboard, memberRepo }; +} + +describe('DashboardService', () => { + let db: SqliteDatabase; + let dashboard: DashboardService; + let projectId: number; + let userId: number; + let outsiderId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + const ctx = makeServices(db); + dashboard = ctx.dashboard; + const users = new UserRepository(db); + userId = users.create({ + name: 'U', + email: 'u@example.com', + passwordHash: 'h', + }).id; + outsiderId = users.create({ + name: 'O', + email: 'o@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: userId, + }).id; + ctx.memberRepo.add(projectId, userId, 'admin'); + }); + + afterEach(() => db.close()); + + it('getProjectDashboard aggregates the project resources', () => { + const col = new TodoRepository(db).createColumn({ + projectId, + name: 'Todo', + orderIndex: 0, + }).id; + const todoRepo = new TodoRepository(db); + todoRepo.createItem({ + projectId, + columnId: col, + title: 't1', + creatorId: userId, + orderIndex: 0, + dueDate: '2099-01-01', + }); + todoRepo.createItem({ + projectId, + columnId: col, + title: 'done', + creatorId: userId, + orderIndex: 1, + }); + todoRepo.updateItem(2, { completedAt: '2099-01-01T00:00:00.000Z' }); + new ChatRepository(db).create({ projectId, authorId: userId, body: 'hi' }); + new BoardRepository(db).createThread({ + projectId, + title: 'thread', + bodyMd: 'b', + authorId: userId, + category: null, + }); + new ProjectNoteRepository(db).create({ + projectId, + title: 'note', + bodyMd: 'b', + tags: null, + createdById: userId, + }); + new FileRepository(db).create({ + projectId, + uploaderId: userId, + filename: 'a.bin', + originalName: 'a', + mimeType: 'text/plain', + size: 1, + path: '/tmp/a', + }); + new MeetingRepository(db).create({ + projectId, + title: 'future', + startAt: '2099-06-15T10:00:00', + endAt: '2099-06-15T11:00:00', + createdById: userId, + }); + new MilestoneRepository(db).create({ projectId, title: 'M' }); + + const d = dashboard.getProjectDashboard(userId, projectId); + expect(d.project.id).toBe(projectId); + expect(d.inProgressTodos).toHaveLength(1); // t1 not completed, 'done' is completed + expect(d.latestChat).toHaveLength(1); + expect(d.latestBoard).toHaveLength(1); + expect(d.latestNotes).toHaveLength(1); + expect(d.recentFiles).toHaveLength(1); + expect(d.nextMeeting?.title).toBe('future'); + expect(d.milestones).toHaveLength(1); + }); + + it('getProjectDashboard forbids a non-member', () => { + expect(() => dashboard.getProjectDashboard(outsiderId, projectId)).toThrow( + ForbiddenError + ); + }); + + it('getPersonalDashboard returns my projects, todos, notifications', () => { + const col = new TodoRepository(db).createColumn({ + projectId, + name: 'Todo', + orderIndex: 0, + }).id; + new TodoRepository(db).createItem({ + projectId, + columnId: col, + title: 'mine', + creatorId: userId, + assigneeId: userId, + orderIndex: 0, + dueDate: '2099-01-01', + }); + new NotificationRepository(db).create({ + userId, + projectId, + type: 'mention', + title: 'n', + body: null, + }); + + const d = dashboard.getPersonalDashboard(userId); + expect(d.projects.map((p) => p.id)).toContain(projectId); + expect(d.incompleteTodos).toHaveLength(1); + expect(d.unreadNotificationCount).toBe(1); + expect(d.overdueTasks).toHaveLength(0); // due 2099, not overdue + }); +}); diff --git a/tests/unit/services/SearchService.test.ts b/tests/unit/services/SearchService.test.ts new file mode 100644 index 0000000..c8d4d67 --- /dev/null +++ b/tests/unit/services/SearchService.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +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 { SearchService } from '@/services/SearchService'; +import { ForbiddenError } from '@/lib/errors'; + +function makeService(db: SqliteDatabase) { + return new SearchService( + new BoardRepository(db), + new ChatRepository(db), + new TodoRepository(db), + new FileRepository(db), + new CalendarRepository(db), + new MeetingRepository(db), + new MilestoneRepository(db), + new ProjectNoteRepository(db), + new ProjectMemberRepository(db) + ); +} + +describe('SearchService', () => { + let db: SqliteDatabase; + let service: SearchService; + let projectId: number; + let userId: number; + let outsiderId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + service = makeService(db); + const users = new UserRepository(db); + userId = users.create({ + name: 'U', + email: 'u@example.com', + passwordHash: 'h', + }).id; + outsiderId = users.create({ + name: 'O', + email: 'o@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: userId, + }).id; + const members = new ProjectMemberRepository(db); + members.add(projectId, userId, 'admin'); + }); + + afterEach(() => db.close()); + + function seedKeyword(keyword: string) { + const col = new TodoRepository(db).createColumn({ + projectId, + name: 'Todo', + orderIndex: 0, + }).id; + new BoardRepository(db).createThread({ + projectId, + title: `thread ${keyword}`, + bodyMd: 'body', + authorId: userId, + category: null, + }); + new ChatRepository(db).create({ + projectId, + authorId: userId, + body: `chat ${keyword}`, + }); + new TodoRepository(db).createItem({ + projectId, + columnId: col, + title: `task ${keyword}`, + creatorId: userId, + orderIndex: 0, + }); + new FileRepository(db).create({ + projectId, + uploaderId: userId, + filename: 'a.bin', + originalName: `file-${keyword}.txt`, + mimeType: 'text/plain', + size: 1, + path: '/tmp/a', + }); + new CalendarRepository(db).create({ + projectId, + title: `event ${keyword}`, + type: 'custom', + startAt: '2026-06-15T10:00:00', + createdById: userId, + }); + new MeetingRepository(db).create({ + projectId, + title: `meeting ${keyword}`, + startAt: '2026-06-15T10:00:00', + endAt: '2026-06-15T11:00:00', + createdById: userId, + }); + new MilestoneRepository(db).create({ projectId, title: `ms ${keyword}` }); + new ProjectNoteRepository(db).create({ + projectId, + title: `note ${keyword}`, + bodyMd: 'body', + tags: null, + createdById: userId, + }); + } + + it('returns results across all resource types for a keyword', () => { + seedKeyword('unicorn'); + const results = service.search(userId, projectId, { q: 'unicorn' }); + const types = new Set(results.map((r) => r.type)); + expect(types.has('thread')).toBe(true); + expect(types.has('chat')).toBe(true); + expect(types.has('todo')).toBe(true); + expect(types.has('file')).toBe(true); + expect(types.has('event')).toBe(true); + expect(types.has('meeting')).toBe(true); + expect(types.has('milestone')).toBe(true); + expect(types.has('note')).toBe(true); + }); + + it('filters by a single type', () => { + seedKeyword('unicorn'); + const results = service.search(userId, projectId, { + q: 'unicorn', + type: 'todo', + }); + expect(results.every((r) => r.type === 'todo')).toBe(true); + expect(results).toHaveLength(1); + }); + + it('returns no results for an unmatched keyword', () => { + seedKeyword('unicorn'); + expect(service.search(userId, projectId, { q: 'nomatch' })).toEqual([]); + }); + + it('returns nothing for an empty query', () => { + seedKeyword('unicorn'); + expect(service.search(userId, projectId, { q: '' })).toEqual([]); + }); + + it('forbids a non-member', () => { + seedKeyword('unicorn'); + expect(() => + service.search(outsiderId, projectId, { q: 'unicorn' }) + ).toThrow(ForbiddenError); + }); +});