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:
19
app/api/dashboard/route.ts
Normal file
19
app/api/dashboard/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/api/projects/[projectId]/search/route.ts
Normal file
32
app/api/projects/[projectId]/search/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
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 { Header } from '@/components/layout/Header';
|
||||||
import { ProjectCard } from '@/components/project/ProjectCard';
|
|
||||||
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
|
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
|
||||||
|
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@ -13,31 +13,75 @@ export default async function DashboardPage() {
|
|||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createProjectService();
|
const service = createDashboardService();
|
||||||
const projects = service.getMyProjects(user.id);
|
const dashboard = service.getPersonalDashboard(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Header user={toPublicUser(user)} />
|
<Header user={toPublicUser(user)} />
|
||||||
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
<main className="mx-auto max-w-4xl space-y-6 p-6">
|
||||||
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
<h1 className="text-2xl font-bold">ダッシュボード</h1>
|
||||||
<CreateProjectForm />
|
|
||||||
<section>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-gray-700">
|
<DashboardWidget title="参加プロジェクト" empty="ありません">
|
||||||
参加プロジェクト
|
{dashboard.projects.map((p) => (
|
||||||
</h2>
|
<a
|
||||||
{projects.length === 0 ? (
|
key={p.id}
|
||||||
<p className="text-sm text-gray-400">
|
href={`/projects/${p.id}`}
|
||||||
参加しているプロジェクトはありません。
|
className="block rounded border px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
</p>
|
>
|
||||||
) : (
|
{p.name}({p.status})
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
</a>
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectCard key={project.id} project={project} />
|
|
||||||
))}
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
<CreateProjectForm />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,43 +1,34 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
|
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 { Header } from '@/components/layout/Header';
|
||||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||||
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
import { DashboardWidget } from '@/components/project/DashboardWidget';
|
||||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
import { ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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({
|
export default async function ProjectOverviewPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ projectId: string }>;
|
params: Promise<{ projectId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) redirect('/login');
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
const service = createProjectService();
|
const service = createDashboardService();
|
||||||
let dashboard;
|
let dashboard;
|
||||||
try {
|
try {
|
||||||
dashboard = service.getDashboard(user.id, Number(projectId));
|
dashboard = service.getProjectDashboard(user.id, Number(projectId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
if (error instanceof ForbiddenError) {
|
||||||
redirect('/dashboard');
|
redirect('/dashboard');
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, members } = dashboard;
|
const { project } = dashboard;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
@ -47,31 +38,107 @@ export default async function ProjectOverviewPage({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">{project.name}</h1>
|
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
|
||||||
{STATUS_LABELS[project.status] ?? project.status}
|
{project.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-gray-600">{project.description}</p>
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<DashboardWidget
|
<DashboardWidget title="進行中ToDo" empty="ありません">
|
||||||
title="進行中ToDo"
|
{dashboard.inProgressTodos.map((t) => (
|
||||||
empty="ToDo機能は後続マイルストーンで実装されます"
|
<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
|
<DashboardWidget
|
||||||
title="最新チャット"
|
title="最近のアクティビティ (10件)"
|
||||||
empty="チャット機能は後続マイルストーンで実装されます"
|
empty="ありません"
|
||||||
/>
|
>
|
||||||
<DashboardWidget
|
{dashboard.recentActivity.map((l) => (
|
||||||
title="最新掲示板"
|
<p key={l.id} className="text-sm">
|
||||||
empty="掲示板機能は後続マイルストーンで実装されます"
|
{l.action}({l.targetType})
|
||||||
/>
|
</p>
|
||||||
<DashboardWidget
|
))}
|
||||||
title="最近のアクティビティ"
|
</DashboardWidget>
|
||||||
empty="アクティビティログは後続マイルストーンで実装されます"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
93
app/projects/[projectId]/search/page.tsx
Normal file
93
app/projects/[projectId]/search/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/calendar', label: 'カレンダー' },
|
{ href: '/calendar', label: 'カレンダー' },
|
||||||
{ href: '/milestones', label: 'マイルストーン' },
|
{ href: '/milestones', label: 'マイルストーン' },
|
||||||
{ href: '/meetings', label: 'ミーティング' },
|
{ href: '/meetings', label: 'ミーティング' },
|
||||||
|
{ href: '/search', label: '検索' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
{ href: '/activity', label: 'アクティビティ' },
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
@ -32,6 +33,7 @@ export function ProjectNav({
|
|||||||
| 'calendar'
|
| 'calendar'
|
||||||
| 'milestones'
|
| 'milestones'
|
||||||
| 'meetings'
|
| 'meetings'
|
||||||
|
| 'search'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'settings';
|
| 'settings';
|
||||||
@ -46,6 +48,7 @@ export function ProjectNav({
|
|||||||
calendar: active === 'calendar',
|
calendar: active === 'calendar',
|
||||||
milestones: active === 'milestones',
|
milestones: active === 'milestones',
|
||||||
meetings: active === 'meetings',
|
meetings: active === 'meetings',
|
||||||
|
search: active === 'search',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
activity: active === 'activity',
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
|
|||||||
72
components/project/SearchForm.tsx
Normal file
72
components/project/SearchForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ import { CalendarRepository } from '@/repositories/CalendarRepository';
|
|||||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||||
import { MeetingService } from '@/services/MeetingService';
|
import { MeetingService } from '@/services/MeetingService';
|
||||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||||
|
import { SearchService } from '@/services/SearchService';
|
||||||
|
import { DashboardService } from '@/services/DashboardService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* 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 {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
return new UserRepository(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,14 @@ export interface CreateEventInput {
|
|||||||
export class CalendarRepository {
|
export class CalendarRepository {
|
||||||
constructor(private readonly db: SqliteDatabase) {}
|
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(
|
findByProjectInRange(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
from: string,
|
from: string,
|
||||||
|
|||||||
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('プロジェクトに参加していません');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/e2e/dashboard.spec.ts
Normal file
85
tests/e2e/dashboard.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
180
tests/unit/services/DashboardService.test.ts
Normal file
180
tests/unit/services/DashboardService.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
161
tests/unit/services/SearchService.test.ts
Normal file
161
tests/unit/services/SearchService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user