Merge branch 'feature/m13-search-dashboard' - M13 search & 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 { 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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
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: '/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',
|
||||
|
||||
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 { 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());
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
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