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:
Ken Yasue
2026-06-25 02:28:17 +02:00
parent a632574fb0
commit 755c242d2b
14 changed files with 1192 additions and 53 deletions

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<CreateProjectForm />
<section>
<h2 className="mb-3 text-sm font-semibold text-gray-700">
</h2>
{projects.length === 0 ? (
<p className="text-sm text-gray-400">
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
<div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget title="参加プロジェクト" empty="ありません">
{dashboard.projects.map((p) => (
<a
key={p.id}
href={`/projects/${p.id}`}
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50"
>
{p.name}{p.status}
</a>
))}
</DashboardWidget>
<DashboardWidget
title={`未読通知 (${dashboard.unreadNotificationCount})`}
empty="未読はありません"
>
<a
href="/notifications"
className="text-sm text-blue-600 hover:underline"
>
</a>
</DashboardWidget>
<DashboardWidget title="未完了ToDo" empty="ありません">
{dashboard.incompleteTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}
{t.dueDate ? `(期限: ${t.dueDate}` : ''}
</p>
))}
</DashboardWidget>
<DashboardWidget title="期限切れタスク" empty="ありません">
{dashboard.overdueTasks.map((t) => (
<p key={t.id} className="text-sm text-red-600">
{t.title}: {t.dueDate}
</p>
))}
</DashboardWidget>
<DashboardWidget title="近日中のミーティング" empty="ありません">
{dashboard.upcomingMeetings.map((m) => (
<p key={m.id} className="text-sm">
{m.title}{m.startAt}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最近のアクティビティ" empty="ありません">
{dashboard.recentActivity.map((l) => (
<p key={l.id} className="text-sm">
{l.action}{l.targetType}
</p>
))}
</DashboardWidget>
</div>
)}
</section>
<CreateProjectForm />
</main>
</div>
);

View File

@ -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<string, string> = {
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 (
<div className="min-h-screen bg-gray-50">
@ -47,31 +38,107 @@ export default async function ProjectOverviewPage({
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{project.name}</h1>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{STATUS_LABELS[project.status] ?? project.status}
{project.status}
</span>
</div>
{project.description && (
<p className="text-gray-600">{project.description}</p>
)}
<p className="text-sm text-gray-500">: {members.length}</p>
<div className="grid gap-4 sm:grid-cols-2">
<DashboardWidget
title="進行中ToDo"
empty="ToDo機能は後続マイルストーンで実装されます"
<DashboardWidget title="進行中ToDo" empty="ありません">
{dashboard.inProgressTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}
{t.dueDate ? `(期限: ${t.dueDate}` : ''}
</p>
))}
</DashboardWidget>
<DashboardWidget title="期限が近いToDo (7日以内)" empty="ありません">
{dashboard.nearDueTodos.map((t) => (
<p key={t.id} className="text-sm">
{t.title}{t.dueDate}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最新チャット (5件)" empty="ありません">
{dashboard.latestChat.map((m) => (
<p key={m.id} className="text-sm">
{m.body}
</p>
))}
</DashboardWidget>
<DashboardWidget title="最新掲示板 (5件)" empty="ありません">
{dashboard.latestBoard.map((t) => (
<a
key={t.id}
href={`/projects/${project.id}/board/${t.id}`}
className="block text-sm text-blue-600 hover:underline"
>
{t.isPinned === 1 ? '📌 ' : ''}
{t.title}
</a>
))}
</DashboardWidget>
<DashboardWidget title="最新メモ (5件)" empty="ありません">
{dashboard.latestNotes.map((n) => (
<a
key={n.id}
href={`/projects/${project.id}/notes/${n.id}`}
className="block text-sm text-blue-600 hover:underline"
>
{n.isPinned === 1 ? '📌 ' : ''}
{n.title}
</a>
))}
</DashboardWidget>
<DashboardWidget title="最近のファイル (5件)" empty="ありません">
{dashboard.recentFiles.map((f) => (
<p key={f.id} className="text-sm">
{f.originalName}
</p>
))}
</DashboardWidget>
<DashboardWidget title="次回ミーティング" empty="予定なし">
{dashboard.nextMeeting && (
<p className="text-sm">
{dashboard.nextMeeting.title}{dashboard.nextMeeting.startAt}
</p>
)}
</DashboardWidget>
<DashboardWidget title="マイルストーン進捗" empty="ありません">
{dashboard.milestones.map((m) => (
<div key={m.id}>
<p className="text-sm">
{m.title} - {m.progress}%
</p>
<div className="h-1.5 w-full rounded bg-gray-200">
<div
className="h-1.5 rounded bg-blue-500"
style={{ width: `${m.progress}%` }}
/>
</div>
</div>
))}
</DashboardWidget>
<DashboardWidget
title="最新チャット"
empty="チャット機能は後続マイルストーンで実装されます"
/>
<DashboardWidget
title="最新掲示板"
empty="掲示板機能は後続マイルストーンで実装されます"
/>
<DashboardWidget
title="最近のアクティビティ"
empty="アクティビティログは後続マイルストーンで実装されます"
/>
title="最近のアクティビティ (10件)"
empty="ありません"
>
{dashboard.recentActivity.map((l) => (
<p key={l.id} className="text-sm">
{l.action}{l.targetType}
</p>
))}
</DashboardWidget>
</div>
</main>
</div>

View File

@ -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, (id: number, projectId: number) => 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<ReturnType<typeof projectService.getProject>>;
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 (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="search" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<SearchForm
projectId={project.id}
initialQ={q ?? ''}
initialType={type ?? ''}
/>
<section className="space-y-2">
{q && results.length === 0 && (
<p className="text-sm text-gray-400"></p>
)}
{results.map((r) => {
const href =
TYPE_LINKS[r.type]?.(r.id, project.id) ??
`/projects/${project.id}`;
return (
<a
key={`${r.type}-${r.id}`}
href={href}
className="block rounded border bg-white p-3 shadow-sm hover:shadow-md"
data-testid={`search-result-${r.type}-${r.id}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-800">{r.title}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{r.type}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">{r.snippet}</p>
</a>
);
})}
</section>
</main>
</div>
);
}

View File

@ -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',

View File

@ -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<HTMLFormElement>) {
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 (
<form
onSubmit={onSubmit}
className="flex flex-col gap-2 rounded-lg border bg-white p-4 shadow-sm sm:flex-row"
data-testid="search-form"
>
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="キーワード"
className="flex-1 rounded border px-3 py-2"
data-testid="search-input"
/>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="rounded border px-2 py-2"
>
{TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
</button>
</form>
);
}

View File

@ -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());
}

View File

@ -50,6 +50,14 @@ export interface CreateEventInput {
export class CalendarRepository {
constructor(private readonly db: SqliteDatabase) {}
findByProject(projectId: number): CalendarEvent[] {
const rows = this.db.query<CalendarEventRow>(
'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,

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

View File

@ -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();
});
});

View File

@ -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
});
});

View File

@ -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);
});
});