diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts
new file mode 100644
index 0000000..eebadfa
--- /dev/null
+++ b/app/api/dashboard/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/auth/getCurrentUser';
+import { createDashboardService } from '@/lib/api/services';
+import { UnauthorizedError } from '@/lib/errors';
+import { handleApiError } from '@/lib/api/handleError';
+
+export const runtime = 'nodejs';
+
+export async function GET() {
+ const user = await getCurrentUser();
+ if (!user) return handleApiError(new UnauthorizedError());
+
+ const service = createDashboardService();
+ try {
+ return NextResponse.json(service.getPersonalDashboard(user.id));
+ } catch (error) {
+ return handleApiError(error);
+ }
+}
diff --git a/app/api/projects/[projectId]/search/route.ts b/app/api/projects/[projectId]/search/route.ts
new file mode 100644
index 0000000..6036d77
--- /dev/null
+++ b/app/api/projects/[projectId]/search/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/auth/getCurrentUser';
+import { createSearchService } from '@/lib/api/services';
+import { UnauthorizedError } from '@/lib/errors';
+import { handleApiError } from '@/lib/api/handleError';
+
+export const runtime = 'nodejs';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ projectId: string }> }
+) {
+ const user = await getCurrentUser();
+ if (!user) return handleApiError(new UnauthorizedError());
+ const { projectId } = await params;
+ const q = request.nextUrl.searchParams.get('q') ?? '';
+ const type = (request.nextUrl.searchParams.get('type') ?? undefined) as
+ | string
+ | undefined;
+
+ const service = createSearchService();
+ try {
+ return NextResponse.json(
+ service.search(user.id, Number(projectId), {
+ q,
+ type: type as never,
+ })
+ );
+ } catch (error) {
+ return handleApiError(error);
+ }
+}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 253da87..7e2d6db 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -1,9 +1,9 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
-import { createProjectService } from '@/lib/api/services';
+import { createDashboardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
-import { ProjectCard } from '@/components/project/ProjectCard';
import { CreateProjectForm } from '@/components/project/CreateProjectForm';
+import { DashboardWidget } from '@/components/project/DashboardWidget';
export const dynamic = 'force-dynamic';
@@ -13,31 +13,75 @@ export default async function DashboardPage() {
redirect('/login');
}
- const service = createProjectService();
- const projects = service.getMyProjects(user.id);
+ const service = createDashboardService();
+ const dashboard = service.getPersonalDashboard(user.id);
return (
ダッシュボード
+
+
+
+ {dashboard.projects.map((p) => (
+
+ {p.name}({p.status})
+
+ ))}
+
+
+
+
+ 通知一覧を開く
+
+
+
+
+ {dashboard.incompleteTodos.map((t) => (
+
+ {t.title}
+ {t.dueDate ? `(期限: ${t.dueDate})` : ''}
+
+ ))}
+
+
+
+ {dashboard.overdueTasks.map((t) => (
+
+ {t.title}(期限: {t.dueDate})
+
+ ))}
+
+
+
+ {dashboard.upcomingMeetings.map((m) => (
+
+ {m.title}({m.startAt})
+
+ ))}
+
+
+
+ {dashboard.recentActivity.map((l) => (
+
+ {l.action}({l.targetType})
+
+ ))}
+
+
+
-
-
- 参加プロジェクト
-
- {projects.length === 0 ? (
-
- 参加しているプロジェクトはありません。
-
- ) : (
-
- {projects.map((project) => (
-
- ))}
-
- )}
-
);
diff --git a/app/projects/[projectId]/page.tsx b/app/projects/[projectId]/page.tsx
index 9a0d3d2..192c801 100644
--- a/app/projects/[projectId]/page.tsx
+++ b/app/projects/[projectId]/page.tsx
@@ -1,43 +1,34 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
-import { createProjectService } from '@/lib/api/services';
+import { createDashboardService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { DashboardWidget } from '@/components/project/DashboardWidget';
-import { ForbiddenError, NotFoundError } from '@/lib/errors';
+import { ForbiddenError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
-const STATUS_LABELS: Record = {
- active: 'Active',
- on_hold: 'On Hold',
- completed: 'Completed',
- archived: 'Archived',
-};
-
export default async function ProjectOverviewPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
- if (!user) {
- redirect('/login');
- }
+ if (!user) redirect('/login');
const { projectId } = await params;
- const service = createProjectService();
+ const service = createDashboardService();
let dashboard;
try {
- dashboard = service.getDashboard(user.id, Number(projectId));
+ dashboard = service.getProjectDashboard(user.id, Number(projectId));
} catch (error) {
- if (error instanceof ForbiddenError || error instanceof NotFoundError) {
+ if (error instanceof ForbiddenError) {
redirect('/dashboard');
}
throw error;
}
- const { project, members } = dashboard;
+ const { project } = dashboard;
return (
@@ -47,31 +38,107 @@ export default async function ProjectOverviewPage({
{project.name}
- {STATUS_LABELS[project.status] ?? project.status}
+ {project.status}
{project.description && (
{project.description}
)}
-
メンバー数: {members.length}
+
+ {dashboard.inProgressTodos.map((t) => (
+
+ {t.title}
+ {t.dueDate ? `(期限: ${t.dueDate})` : ''}
+
+ ))}
+
+
+
+ {dashboard.nearDueTodos.map((t) => (
+
+ {t.title}({t.dueDate})
+
+ ))}
+
+
+
+ {dashboard.latestChat.map((m) => (
+
+ {m.body}
+
+ ))}
+
+
+
+ {dashboard.latestBoard.map((t) => (
+
+ {t.isPinned === 1 ? '📌 ' : ''}
+ {t.title}
+
+ ))}
+
+
+
+ {dashboard.latestNotes.map((n) => (
+
+ {n.isPinned === 1 ? '📌 ' : ''}
+ {n.title}
+
+ ))}
+
+
+
+ {dashboard.recentFiles.map((f) => (
+
+ {f.originalName}
+
+ ))}
+
+
+
+ {dashboard.nextMeeting && (
+
+ {dashboard.nextMeeting.title}({dashboard.nextMeeting.startAt})
+
+ )}
+
+
+
+ {dashboard.milestones.map((m) => (
+
+
+ {m.title} - {m.progress}%
+
+
+
+ ))}
+
+
-
-
-
+ title="最近のアクティビティ (10件)"
+ empty="ありません"
+ >
+ {dashboard.recentActivity.map((l) => (
+
+ {l.action}({l.targetType})
+
+ ))}
+
diff --git a/app/projects/[projectId]/search/page.tsx b/app/projects/[projectId]/search/page.tsx
new file mode 100644
index 0000000..e8e213f
--- /dev/null
+++ b/app/projects/[projectId]/search/page.tsx
@@ -0,0 +1,93 @@
+import { redirect } from 'next/navigation';
+import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
+import { createSearchService, createProjectService } from '@/lib/api/services';
+import { Header } from '@/components/layout/Header';
+import { ProjectNav } from '@/components/layout/ProjectNav';
+import { SearchForm } from '@/components/project/SearchForm';
+import { ForbiddenError, NotFoundError } from '@/lib/errors';
+
+export const dynamic = 'force-dynamic';
+
+const TYPE_LINKS: Record string> = {
+ thread: (id, p) => `/projects/${p}/board/${id}`,
+ note: (id, p) => `/projects/${p}/notes/${id}`,
+ todo: (_id, p) => `/projects/${p}/todos`,
+ file: (id) => `/api/files/${id}/download`,
+ event: (_id, p) => `/projects/${p}/calendar`,
+ meeting: (_id, p) => `/projects/${p}/meetings`,
+ milestone: (_id, p) => `/projects/${p}/milestones`,
+ chat: (_id, p) => `/projects/${p}/chat`,
+};
+
+export default async function SearchPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ projectId: string }>;
+ searchParams: Promise<{ q?: string; type?: string }>;
+}) {
+ const user = await getCurrentUser();
+ if (!user) redirect('/login');
+ const { projectId } = await params;
+ const { q, type } = await searchParams;
+
+ const projectService = createProjectService();
+ let project: Awaited>;
+ try {
+ project = projectService.getProject(user.id, Number(projectId));
+ } catch (error) {
+ if (error instanceof ForbiddenError || error instanceof NotFoundError) {
+ redirect('/dashboard');
+ }
+ throw error;
+ }
+
+ const searchService = createSearchService();
+ const results = q
+ ? searchService.search(user.id, project.id, {
+ q,
+ type: type as never,
+ })
+ : [];
+
+ return (
+
+
+
+
+ 検索
+
+
+ {q && results.length === 0 && (
+ 該当する結果はありません。
+ )}
+ {results.map((r) => {
+ const href =
+ TYPE_LINKS[r.type]?.(r.id, project.id) ??
+ `/projects/${project.id}`;
+ return (
+
+
+ {r.title}
+
+ {r.type}
+
+
+ {r.snippet}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx
index 5e26aae..d2383d5 100644
--- a/components/layout/ProjectNav.tsx
+++ b/components/layout/ProjectNav.tsx
@@ -8,6 +8,7 @@ const NAV_ITEMS = [
{ href: '/calendar', label: 'カレンダー' },
{ href: '/milestones', label: 'マイルストーン' },
{ href: '/meetings', label: 'ミーティング' },
+ { href: '/search', label: '検索' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
@@ -32,6 +33,7 @@ export function ProjectNav({
| 'calendar'
| 'milestones'
| 'meetings'
+ | 'search'
| 'members'
| 'activity'
| 'settings';
@@ -46,6 +48,7 @@ export function ProjectNav({
calendar: active === 'calendar',
milestones: active === 'milestones',
meetings: active === 'meetings',
+ search: active === 'search',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',
diff --git a/components/project/SearchForm.tsx b/components/project/SearchForm.tsx
new file mode 100644
index 0000000..62fe8b4
--- /dev/null
+++ b/components/project/SearchForm.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { useState, type FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+
+const TYPES = [
+ { value: '', label: 'すべて' },
+ { value: 'thread', label: '掲示板' },
+ { value: 'chat', label: 'チャット' },
+ { value: 'todo', label: 'ToDo' },
+ { value: 'file', label: 'ファイル' },
+ { value: 'event', label: 'イベント' },
+ { value: 'meeting', label: 'ミーティング' },
+ { value: 'milestone', label: 'マイルストーン' },
+ { value: 'note', label: 'メモ' },
+];
+
+export function SearchForm({
+ projectId,
+ initialQ,
+ initialType,
+}: {
+ projectId: number;
+ initialQ: string;
+ initialType: string;
+}) {
+ const router = useRouter();
+ const [q, setQ] = useState(initialQ);
+ const [type, setType] = useState(initialType);
+
+ function onSubmit(event: FormEvent) {
+ event.preventDefault();
+ const params = new URLSearchParams();
+ if (q) params.set('q', q);
+ if (type) params.set('type', type);
+ router.push(`/projects/${projectId}/search?${params.toString()}`);
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/api/services.ts b/lib/api/services.ts
index 295b946..9e6489a 100644
--- a/lib/api/services.ts
+++ b/lib/api/services.ts
@@ -23,6 +23,8 @@ import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
import { MeetingService } from '@/services/MeetingService';
import { MeetingRepository } from '@/repositories/MeetingRepository';
+import { SearchService } from '@/services/SearchService';
+import { DashboardService } from '@/services/DashboardService';
/**
* Route Handler用に各Repository/Serviceを構築するファクトリ。
@@ -126,6 +128,46 @@ export function createMeetingService(): MeetingService {
);
}
+export function createSearchService(): SearchService {
+ const db = getDb();
+ return new SearchService(
+ new BoardRepository(db),
+ new ChatRepository(db),
+ new TodoRepository(db),
+ new FileRepository(db),
+ new CalendarRepository(db),
+ new MeetingRepository(db),
+ new MilestoneRepository(db),
+ new ProjectNoteRepository(db),
+ new ProjectMemberRepository(db)
+ );
+}
+
+export function createDashboardService(): DashboardService {
+ const db = getDb();
+ const memberRepo = new ProjectMemberRepository(db);
+ const scheduleService = new ScheduleService(
+ new CalendarRepository(db),
+ new MilestoneRepository(db),
+ new TodoRepository(db),
+ memberRepo,
+ new ActivityLogService(new ActivityLogRepository(db))
+ );
+ return new DashboardService(
+ new ProjectRepository(db),
+ new TodoRepository(db),
+ new ChatRepository(db),
+ new BoardRepository(db),
+ new ProjectNoteRepository(db),
+ new FileRepository(db),
+ new MeetingRepository(db),
+ new ActivityLogRepository(db),
+ new NotificationRepository(db),
+ memberRepo,
+ scheduleService
+ );
+}
+
export function createUserRepository(): UserRepository {
return new UserRepository(getDb());
}
diff --git a/repositories/CalendarRepository.ts b/repositories/CalendarRepository.ts
index 698b1d9..82e8d9e 100644
--- a/repositories/CalendarRepository.ts
+++ b/repositories/CalendarRepository.ts
@@ -50,6 +50,14 @@ export interface CreateEventInput {
export class CalendarRepository {
constructor(private readonly db: SqliteDatabase) {}
+ findByProject(projectId: number): CalendarEvent[] {
+ const rows = this.db.query(
+ 'SELECT * FROM calendar_events WHERE project_id = @projectId AND deleted_at IS NULL ORDER BY start_at ASC, id ASC',
+ { projectId }
+ );
+ return rows.map(mapEvent);
+ }
+
findByProjectInRange(
projectId: number,
from: string,
diff --git a/services/DashboardService.ts b/services/DashboardService.ts
new file mode 100644
index 0000000..bb75d13
--- /dev/null
+++ b/services/DashboardService.ts
@@ -0,0 +1,155 @@
+import { ProjectRepository } from '@/repositories/ProjectRepository';
+import { TodoRepository } from '@/repositories/TodoRepository';
+import { ChatRepository } from '@/repositories/ChatRepository';
+import { BoardRepository } from '@/repositories/BoardRepository';
+import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
+import { FileRepository } from '@/repositories/FileRepository';
+import { MeetingRepository } from '@/repositories/MeetingRepository';
+import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
+import { NotificationRepository } from '@/repositories/NotificationRepository';
+import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
+import { ScheduleService } from '@/services/ScheduleService';
+import { ForbiddenError } from '@/lib/errors';
+import type {
+ ActivityLog,
+ BoardThread,
+ ChatMessage,
+ FileAsset,
+ Meeting,
+ Project,
+ ProjectNote,
+ TodoItem,
+} from '@/lib/types';
+
+// MilestoneWithProgress is exported from ScheduleService; derive structurally
+type MilestoneWithProgress = ReturnType<
+ ScheduleService['getMilestones']
+>[number];
+
+export interface ProjectDashboard {
+ project: Project;
+ inProgressTodos: TodoItem[];
+ nearDueTodos: TodoItem[];
+ latestChat: ChatMessage[];
+ latestBoard: BoardThread[];
+ latestNotes: ProjectNote[];
+ recentFiles: FileAsset[];
+ nextMeeting: Meeting | null;
+ milestones: MilestoneWithProgress[];
+ recentActivity: ActivityLog[];
+}
+
+export interface PersonalDashboard {
+ projects: Project[];
+ incompleteTodos: TodoItem[];
+ upcomingMeetings: Meeting[];
+ unreadNotificationCount: number;
+ overdueTasks: TodoItem[];
+ recentActivity: ActivityLog[];
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * 個人/プロジェクトダッシュボードの集計を担うService。
+ */
+export class DashboardService {
+ constructor(
+ private readonly projectRepository: ProjectRepository,
+ private readonly todoRepository: TodoRepository,
+ private readonly chatRepository: ChatRepository,
+ private readonly boardRepository: BoardRepository,
+ private readonly noteRepository: ProjectNoteRepository,
+ private readonly fileRepository: FileRepository,
+ private readonly meetingRepository: MeetingRepository,
+ private readonly activityLogRepository: ActivityLogRepository,
+ private readonly notificationRepository: NotificationRepository,
+ private readonly projectMemberRepository: ProjectMemberRepository,
+ private readonly scheduleService: ScheduleService
+ ) {}
+
+ getProjectDashboard(actorId: number, projectId: number): ProjectDashboard {
+ const project = this.projectRepository.findById(projectId);
+ if (!project) throw new ForbiddenError('プロジェクトに参加していません');
+ if (!this.projectMemberRepository.isMember(projectId, actorId)) {
+ throw new ForbiddenError('プロジェクトに参加していません');
+ }
+
+ const todos = this.todoRepository.findItemsByProject(projectId);
+ const now = new Date();
+ const nowIso = now.toISOString();
+ const sevenDaysLater = new Date(now.getTime() + 7 * DAY_MS).toISOString();
+ const todayPrefix = nowIso.slice(0, 10);
+
+ return {
+ project,
+ inProgressTodos: todos.filter((t) => t.completedAt === null),
+ nearDueTodos: todos.filter(
+ (t) =>
+ t.completedAt === null &&
+ t.dueDate !== null &&
+ t.dueDate >= todayPrefix &&
+ t.dueDate <= sevenDaysLater.slice(0, 10)
+ ),
+ latestChat: this.chatRepository.findMessages(projectId, {
+ page: 1,
+ pageSize: 5,
+ }).items,
+ latestBoard: this.boardRepository.findThreads(projectId, {
+ page: 1,
+ pageSize: 5,
+ }).items,
+ latestNotes: this.noteRepository.findNotes(projectId, {
+ page: 1,
+ pageSize: 5,
+ }).items,
+ recentFiles: this.fileRepository.findFilesByProject(projectId, 1, 5)
+ .items,
+ nextMeeting:
+ this.meetingRepository
+ .findByProject(projectId)
+ .filter((m) => m.startAt >= nowIso)
+ .sort((a, b) => (a.startAt < b.startAt ? -1 : 1))[0] ?? null,
+ milestones: this.scheduleService.getMilestones(actorId, projectId),
+ recentActivity: this.activityLogRepository.findByProject(projectId, 1, 10)
+ .items,
+ };
+ }
+
+ getPersonalDashboard(userId: number): PersonalDashboard {
+ const projects = this.projectRepository.findProjectsByUserId(userId);
+ const nowIso = new Date().toISOString();
+ const todayPrefix = nowIso.slice(0, 10);
+ const in30Days = new Date(Date.now() + 30 * DAY_MS).toISOString();
+
+ const incompleteTodos: TodoItem[] = [];
+ const recentActivity: ActivityLog[] = [];
+ for (const project of projects) {
+ const todos = this.todoRepository
+ .findItemsByProject(project.id)
+ .filter((t) => t.assigneeId === userId && t.completedAt === null);
+ incompleteTodos.push(...todos);
+ recentActivity.push(
+ ...this.activityLogRepository.findByProject(project.id, 1, 10).items
+ );
+ }
+ recentActivity.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
+
+ const upcomingMeetings = this.meetingRepository
+ .findMeetingsByUserInRange(userId, nowIso, in30Days)
+ .sort((a, b) => (a.startAt < b.startAt ? -1 : 1))
+ .slice(0, 5);
+
+ return {
+ projects,
+ incompleteTodos,
+ upcomingMeetings,
+ unreadNotificationCount:
+ this.notificationRepository.countUnreadByUser(userId),
+ overdueTasks: incompleteTodos.filter(
+ (t) => t.dueDate !== null && t.dueDate < todayPrefix
+ ),
+ recentActivity: recentActivity.slice(0, 10),
+ };
+ }
+}
diff --git a/services/SearchService.ts b/services/SearchService.ts
new file mode 100644
index 0000000..206fca5
--- /dev/null
+++ b/services/SearchService.ts
@@ -0,0 +1,178 @@
+import { BoardRepository } from '@/repositories/BoardRepository';
+import { ChatRepository } from '@/repositories/ChatRepository';
+import { TodoRepository } from '@/repositories/TodoRepository';
+import { FileRepository } from '@/repositories/FileRepository';
+import { CalendarRepository } from '@/repositories/CalendarRepository';
+import { MeetingRepository } from '@/repositories/MeetingRepository';
+import { MilestoneRepository } from '@/repositories/MilestoneRepository';
+import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
+import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
+import { ForbiddenError } from '@/lib/errors';
+
+export type SearchResourceType =
+ | 'thread'
+ | 'chat'
+ | 'todo'
+ | 'file'
+ | 'event'
+ | 'meeting'
+ | 'milestone'
+ | 'note';
+
+export interface SearchResult {
+ type: SearchResourceType;
+ id: number;
+ title: string;
+ snippet: string;
+}
+
+export interface SearchOptions {
+ q: string;
+ type?: SearchResourceType;
+}
+
+/**
+ * プロジェクト内の横断検索を担うService。
+ * 各リソースを取得しキーワードで絞り込む(小規模データ前提)。
+ */
+export class SearchService {
+ constructor(
+ private readonly boardRepository: BoardRepository,
+ private readonly chatRepository: ChatRepository,
+ private readonly todoRepository: TodoRepository,
+ private readonly fileRepository: FileRepository,
+ private readonly calendarRepository: CalendarRepository,
+ private readonly meetingRepository: MeetingRepository,
+ private readonly milestoneRepository: MilestoneRepository,
+ private readonly noteRepository: ProjectNoteRepository,
+ private readonly projectMemberRepository: ProjectMemberRepository
+ ) {}
+
+ search(
+ actorId: number,
+ projectId: number,
+ opts: SearchOptions
+ ): SearchResult[] {
+ this.requireMember(projectId, actorId);
+ const q = opts.q.trim().toLowerCase();
+ if (!q) return [];
+ const type = opts.type;
+ const results: SearchResult[] = [];
+ const match = (text: string | null | undefined) =>
+ !!text && text.toLowerCase().includes(q);
+
+ if (!type || type === 'thread') {
+ for (const t of this.boardRepository.findThreads(projectId).items) {
+ if (match(t.title) || match(t.bodyMd)) {
+ results.push({
+ type: 'thread',
+ id: t.id,
+ title: t.title,
+ snippet: t.bodyMd.slice(0, 80),
+ });
+ }
+ }
+ }
+ if (!type || type === 'chat') {
+ for (const m of this.chatRepository.findMessages(projectId).items) {
+ if (match(m.body)) {
+ results.push({
+ type: 'chat',
+ id: m.id,
+ title: m.body.slice(0, 40),
+ snippet: m.body.slice(0, 80),
+ });
+ }
+ }
+ }
+ if (!type || type === 'todo') {
+ for (const t of this.todoRepository.findItemsByProject(projectId)) {
+ if (match(t.title) || match(t.description)) {
+ results.push({
+ type: 'todo',
+ id: t.id,
+ title: t.title,
+ snippet: t.description?.slice(0, 80) ?? '',
+ });
+ }
+ }
+ }
+ if (!type || type === 'file') {
+ const files = this.fileRepository.findFilesByProject(
+ projectId,
+ 1,
+ 500
+ ).items;
+ for (const f of files) {
+ if (match(f.originalName)) {
+ results.push({
+ type: 'file',
+ id: f.id,
+ title: f.originalName,
+ snippet: f.mimeType,
+ });
+ }
+ }
+ }
+ if (!type || type === 'event') {
+ for (const e of this.calendarRepository.findByProject(projectId)) {
+ if (match(e.title) || match(e.description)) {
+ results.push({
+ type: 'event',
+ id: e.id,
+ title: e.title,
+ snippet: e.description?.slice(0, 80) ?? '',
+ });
+ }
+ }
+ }
+ if (!type || type === 'meeting') {
+ for (const m of this.meetingRepository.findByProject(projectId)) {
+ if (
+ match(m.title) ||
+ match(m.description) ||
+ match(m.agendaMd) ||
+ match(m.minutesMd)
+ ) {
+ results.push({
+ type: 'meeting',
+ id: m.id,
+ title: m.title,
+ snippet: m.description?.slice(0, 80) ?? '',
+ });
+ }
+ }
+ }
+ if (!type || type === 'milestone') {
+ for (const m of this.milestoneRepository.findByProject(projectId)) {
+ if (match(m.title) || match(m.description)) {
+ results.push({
+ type: 'milestone',
+ id: m.id,
+ title: m.title,
+ snippet: m.description?.slice(0, 80) ?? '',
+ });
+ }
+ }
+ }
+ if (!type || type === 'note') {
+ for (const n of this.noteRepository.findNotes(projectId).items) {
+ if (match(n.title) || match(n.bodyMd) || match(n.tags)) {
+ results.push({
+ type: 'note',
+ id: n.id,
+ title: n.title,
+ snippet: n.bodyMd.slice(0, 80),
+ });
+ }
+ }
+ }
+ return results;
+ }
+
+ private requireMember(projectId: number, actorId: number): void {
+ if (!this.projectMemberRepository.isMember(projectId, actorId)) {
+ throw new ForbiddenError('プロジェクトに参加していません');
+ }
+ }
+}
diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts
new file mode 100644
index 0000000..203d80d
--- /dev/null
+++ b/tests/e2e/dashboard.spec.ts
@@ -0,0 +1,85 @@
+import { test, expect } from '@playwright/test';
+
+function unique(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
+}
+
+async function setupOwner(page: import('@playwright/test').Page) {
+ const email = unique('owner') + '@example.com';
+ await page.goto('/login');
+ await page.getByRole('button', { name: '新規登録はこちら' }).click();
+ await page.getByLabel('表示名').fill('Owner');
+ await page.getByLabel('メールアドレス').fill(email);
+ await page.getByLabel('パスワード').fill('password123');
+ await page.getByRole('button', { name: '登録する' }).click();
+ await expect(page).toHaveURL(/\/dashboard/);
+ await page.getByLabel('プロジェクト名').fill(unique('Proj'));
+ await page.getByRole('button', { name: '新規プロジェクト' }).click();
+ await expect(page).toHaveURL(/\/projects\/\d+$/);
+ return Number(page.url().match(/\/projects\/(\d+)/)![1]);
+}
+
+test.describe('dashboard & search', () => {
+ test('personal dashboard, project dashboard, and search', async ({
+ page,
+ }) => {
+ const projectId = await setupOwner(page);
+ const keyword = unique('unicorn');
+
+ // データ作成: 掲示板スレッド + ToDo
+ await page.request.post(`/api/projects/${projectId}/board/threads`, {
+ data: { title: `thread ${keyword}`, bodyMd: 'body', category: 'notice' },
+ });
+ const cols = (
+ (await (
+ await page.request.get(`/api/projects/${projectId}/todos/columns`)
+ ).json()) as {
+ columns: { id: number }[];
+ }
+ ).columns;
+ await page.request.post(`/api/projects/${projectId}/todos/items`, {
+ data: {
+ title: `task ${keyword}`,
+ columnId: cols[0].id,
+ assigneeId: undefined,
+ },
+ });
+ // 自分(owner)のIDを取得してToDo担当にする
+ const me = (await (await page.request.get('/api/auth/me')).json()) as {
+ user: { id: number };
+ };
+ const items = (
+ (await (
+ await page.request.get(`/api/projects/${projectId}/todos/items`)
+ ).json()) as {
+ items: { id: number; title: string }[];
+ }
+ ).items;
+ const taskId = items.find((i) => i.title.includes(keyword))!.id;
+ await page.request.patch(
+ `/api/projects/${projectId}/todos/items/${taskId}`,
+ {
+ data: { assigneeId: me.user.id },
+ }
+ );
+
+ // 個人ダッシュボード: プロジェクト + 未完了ToDo
+ await page.goto('/dashboard');
+ await expect(page.getByText(unique('Proj').split('-')[0])).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByText(`task ${keyword}`)).toBeVisible();
+
+ // プロジェクトダッシュボード: 進行中ToDo + 最新掲示板
+ await page.goto(`/projects/${projectId}`);
+ await expect(page.getByText(`task ${keyword}`)).toBeVisible();
+ await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
+
+ // 検索
+ await page.goto(
+ `/projects/${projectId}/search?q=${encodeURIComponent(keyword)}`
+ );
+ await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
+ await expect(page.getByText(`task ${keyword}`)).toBeVisible();
+ });
+});
diff --git a/tests/unit/services/DashboardService.test.ts b/tests/unit/services/DashboardService.test.ts
new file mode 100644
index 0000000..a452b37
--- /dev/null
+++ b/tests/unit/services/DashboardService.test.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { createMigratedTestDb } from '@/tests/helpers/db';
+import type { SqliteDatabase } from '@/lib/db/sqlite';
+import { UserRepository } from '@/repositories/UserRepository';
+import { ProjectRepository } from '@/repositories/ProjectRepository';
+import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
+import { TodoRepository } from '@/repositories/TodoRepository';
+import { ChatRepository } from '@/repositories/ChatRepository';
+import { BoardRepository } from '@/repositories/BoardRepository';
+import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
+import { FileRepository } from '@/repositories/FileRepository';
+import { MeetingRepository } from '@/repositories/MeetingRepository';
+import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
+import { NotificationRepository } from '@/repositories/NotificationRepository';
+import { CalendarRepository } from '@/repositories/CalendarRepository';
+import { MilestoneRepository } from '@/repositories/MilestoneRepository';
+import { ActivityLogService } from '@/services/ActivityLogService';
+import { ScheduleService } from '@/services/ScheduleService';
+import { DashboardService } from '@/services/DashboardService';
+import { ForbiddenError } from '@/lib/errors';
+
+function makeServices(db: SqliteDatabase) {
+ const memberRepo = new ProjectMemberRepository(db);
+ const scheduleService = new ScheduleService(
+ new CalendarRepository(db),
+ new MilestoneRepository(db),
+ new TodoRepository(db),
+ memberRepo,
+ new ActivityLogService(new ActivityLogRepository(db))
+ );
+ const dashboard = new DashboardService(
+ new ProjectRepository(db),
+ new TodoRepository(db),
+ new ChatRepository(db),
+ new BoardRepository(db),
+ new ProjectNoteRepository(db),
+ new FileRepository(db),
+ new MeetingRepository(db),
+ new ActivityLogRepository(db),
+ new NotificationRepository(db),
+ memberRepo,
+ scheduleService
+ );
+ return { dashboard, memberRepo };
+}
+
+describe('DashboardService', () => {
+ let db: SqliteDatabase;
+ let dashboard: DashboardService;
+ let projectId: number;
+ let userId: number;
+ let outsiderId: number;
+
+ beforeEach(() => {
+ db = createMigratedTestDb();
+ const ctx = makeServices(db);
+ dashboard = ctx.dashboard;
+ const users = new UserRepository(db);
+ userId = users.create({
+ name: 'U',
+ email: 'u@example.com',
+ passwordHash: 'h',
+ }).id;
+ outsiderId = users.create({
+ name: 'O',
+ email: 'o@example.com',
+ passwordHash: 'h',
+ }).id;
+ projectId = new ProjectRepository(db).create({
+ name: 'P',
+ ownerId: userId,
+ }).id;
+ ctx.memberRepo.add(projectId, userId, 'admin');
+ });
+
+ afterEach(() => db.close());
+
+ it('getProjectDashboard aggregates the project resources', () => {
+ const col = new TodoRepository(db).createColumn({
+ projectId,
+ name: 'Todo',
+ orderIndex: 0,
+ }).id;
+ const todoRepo = new TodoRepository(db);
+ todoRepo.createItem({
+ projectId,
+ columnId: col,
+ title: 't1',
+ creatorId: userId,
+ orderIndex: 0,
+ dueDate: '2099-01-01',
+ });
+ todoRepo.createItem({
+ projectId,
+ columnId: col,
+ title: 'done',
+ creatorId: userId,
+ orderIndex: 1,
+ });
+ todoRepo.updateItem(2, { completedAt: '2099-01-01T00:00:00.000Z' });
+ new ChatRepository(db).create({ projectId, authorId: userId, body: 'hi' });
+ new BoardRepository(db).createThread({
+ projectId,
+ title: 'thread',
+ bodyMd: 'b',
+ authorId: userId,
+ category: null,
+ });
+ new ProjectNoteRepository(db).create({
+ projectId,
+ title: 'note',
+ bodyMd: 'b',
+ tags: null,
+ createdById: userId,
+ });
+ new FileRepository(db).create({
+ projectId,
+ uploaderId: userId,
+ filename: 'a.bin',
+ originalName: 'a',
+ mimeType: 'text/plain',
+ size: 1,
+ path: '/tmp/a',
+ });
+ new MeetingRepository(db).create({
+ projectId,
+ title: 'future',
+ startAt: '2099-06-15T10:00:00',
+ endAt: '2099-06-15T11:00:00',
+ createdById: userId,
+ });
+ new MilestoneRepository(db).create({ projectId, title: 'M' });
+
+ const d = dashboard.getProjectDashboard(userId, projectId);
+ expect(d.project.id).toBe(projectId);
+ expect(d.inProgressTodos).toHaveLength(1); // t1 not completed, 'done' is completed
+ expect(d.latestChat).toHaveLength(1);
+ expect(d.latestBoard).toHaveLength(1);
+ expect(d.latestNotes).toHaveLength(1);
+ expect(d.recentFiles).toHaveLength(1);
+ expect(d.nextMeeting?.title).toBe('future');
+ expect(d.milestones).toHaveLength(1);
+ });
+
+ it('getProjectDashboard forbids a non-member', () => {
+ expect(() => dashboard.getProjectDashboard(outsiderId, projectId)).toThrow(
+ ForbiddenError
+ );
+ });
+
+ it('getPersonalDashboard returns my projects, todos, notifications', () => {
+ const col = new TodoRepository(db).createColumn({
+ projectId,
+ name: 'Todo',
+ orderIndex: 0,
+ }).id;
+ new TodoRepository(db).createItem({
+ projectId,
+ columnId: col,
+ title: 'mine',
+ creatorId: userId,
+ assigneeId: userId,
+ orderIndex: 0,
+ dueDate: '2099-01-01',
+ });
+ new NotificationRepository(db).create({
+ userId,
+ projectId,
+ type: 'mention',
+ title: 'n',
+ body: null,
+ });
+
+ const d = dashboard.getPersonalDashboard(userId);
+ expect(d.projects.map((p) => p.id)).toContain(projectId);
+ expect(d.incompleteTodos).toHaveLength(1);
+ expect(d.unreadNotificationCount).toBe(1);
+ expect(d.overdueTasks).toHaveLength(0); // due 2099, not overdue
+ });
+});
diff --git a/tests/unit/services/SearchService.test.ts b/tests/unit/services/SearchService.test.ts
new file mode 100644
index 0000000..c8d4d67
--- /dev/null
+++ b/tests/unit/services/SearchService.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { createMigratedTestDb } from '@/tests/helpers/db';
+import type { SqliteDatabase } from '@/lib/db/sqlite';
+import { UserRepository } from '@/repositories/UserRepository';
+import { ProjectRepository } from '@/repositories/ProjectRepository';
+import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
+import { BoardRepository } from '@/repositories/BoardRepository';
+import { ChatRepository } from '@/repositories/ChatRepository';
+import { TodoRepository } from '@/repositories/TodoRepository';
+import { FileRepository } from '@/repositories/FileRepository';
+import { CalendarRepository } from '@/repositories/CalendarRepository';
+import { MeetingRepository } from '@/repositories/MeetingRepository';
+import { MilestoneRepository } from '@/repositories/MilestoneRepository';
+import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
+import { SearchService } from '@/services/SearchService';
+import { ForbiddenError } from '@/lib/errors';
+
+function makeService(db: SqliteDatabase) {
+ return new SearchService(
+ new BoardRepository(db),
+ new ChatRepository(db),
+ new TodoRepository(db),
+ new FileRepository(db),
+ new CalendarRepository(db),
+ new MeetingRepository(db),
+ new MilestoneRepository(db),
+ new ProjectNoteRepository(db),
+ new ProjectMemberRepository(db)
+ );
+}
+
+describe('SearchService', () => {
+ let db: SqliteDatabase;
+ let service: SearchService;
+ let projectId: number;
+ let userId: number;
+ let outsiderId: number;
+
+ beforeEach(() => {
+ db = createMigratedTestDb();
+ service = makeService(db);
+ const users = new UserRepository(db);
+ userId = users.create({
+ name: 'U',
+ email: 'u@example.com',
+ passwordHash: 'h',
+ }).id;
+ outsiderId = users.create({
+ name: 'O',
+ email: 'o@example.com',
+ passwordHash: 'h',
+ }).id;
+ projectId = new ProjectRepository(db).create({
+ name: 'P',
+ ownerId: userId,
+ }).id;
+ const members = new ProjectMemberRepository(db);
+ members.add(projectId, userId, 'admin');
+ });
+
+ afterEach(() => db.close());
+
+ function seedKeyword(keyword: string) {
+ const col = new TodoRepository(db).createColumn({
+ projectId,
+ name: 'Todo',
+ orderIndex: 0,
+ }).id;
+ new BoardRepository(db).createThread({
+ projectId,
+ title: `thread ${keyword}`,
+ bodyMd: 'body',
+ authorId: userId,
+ category: null,
+ });
+ new ChatRepository(db).create({
+ projectId,
+ authorId: userId,
+ body: `chat ${keyword}`,
+ });
+ new TodoRepository(db).createItem({
+ projectId,
+ columnId: col,
+ title: `task ${keyword}`,
+ creatorId: userId,
+ orderIndex: 0,
+ });
+ new FileRepository(db).create({
+ projectId,
+ uploaderId: userId,
+ filename: 'a.bin',
+ originalName: `file-${keyword}.txt`,
+ mimeType: 'text/plain',
+ size: 1,
+ path: '/tmp/a',
+ });
+ new CalendarRepository(db).create({
+ projectId,
+ title: `event ${keyword}`,
+ type: 'custom',
+ startAt: '2026-06-15T10:00:00',
+ createdById: userId,
+ });
+ new MeetingRepository(db).create({
+ projectId,
+ title: `meeting ${keyword}`,
+ startAt: '2026-06-15T10:00:00',
+ endAt: '2026-06-15T11:00:00',
+ createdById: userId,
+ });
+ new MilestoneRepository(db).create({ projectId, title: `ms ${keyword}` });
+ new ProjectNoteRepository(db).create({
+ projectId,
+ title: `note ${keyword}`,
+ bodyMd: 'body',
+ tags: null,
+ createdById: userId,
+ });
+ }
+
+ it('returns results across all resource types for a keyword', () => {
+ seedKeyword('unicorn');
+ const results = service.search(userId, projectId, { q: 'unicorn' });
+ const types = new Set(results.map((r) => r.type));
+ expect(types.has('thread')).toBe(true);
+ expect(types.has('chat')).toBe(true);
+ expect(types.has('todo')).toBe(true);
+ expect(types.has('file')).toBe(true);
+ expect(types.has('event')).toBe(true);
+ expect(types.has('meeting')).toBe(true);
+ expect(types.has('milestone')).toBe(true);
+ expect(types.has('note')).toBe(true);
+ });
+
+ it('filters by a single type', () => {
+ seedKeyword('unicorn');
+ const results = service.search(userId, projectId, {
+ q: 'unicorn',
+ type: 'todo',
+ });
+ expect(results.every((r) => r.type === 'todo')).toBe(true);
+ expect(results).toHaveLength(1);
+ });
+
+ it('returns no results for an unmatched keyword', () => {
+ seedKeyword('unicorn');
+ expect(service.search(userId, projectId, { q: 'nomatch' })).toEqual([]);
+ });
+
+ it('returns nothing for an empty query', () => {
+ seedKeyword('unicorn');
+ expect(service.search(userId, projectId, { q: '' })).toEqual([]);
+ });
+
+ it('forbids a non-member', () => {
+ seedKeyword('unicorn');
+ expect(() =>
+ service.search(outsiderId, projectId, { q: 'unicorn' })
+ ).toThrow(ForbiddenError);
+ });
+});