feat(m13): cross-resource search + dashboard completion, tests, e2e

- SearchService (cross-resource keyword search across board/chat/todo/file/
  event/meeting/milestone/note with type filter, project isolation)
- DashboardService (project dashboard: in-progress todos, near-due(7d),
  latest chat/board/notes/files(5), next meeting, milestones w/ progress,
  recent activity(10); personal dashboard: my projects, incomplete todos,
  upcoming meetings, unread notifications, overdue tasks, recent activity)
- CalendarRepository.findByProject
- APIs: GET /api/projects/:projectId/search, GET /api/dashboard
- Screens: complete personal dashboard, complete project overview,
  search page + SearchForm, ProjectNav 検索 link
- Unit tests (SearchService, DashboardService) + e2e dashboard
This commit is contained in:
Ken Yasue
2026-06-25 02:28:17 +02:00
parent a632574fb0
commit 755c242d2b
14 changed files with 1192 additions and 53 deletions

View File

@ -0,0 +1,85 @@
import { test, expect } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function setupOwner(page: import('@playwright/test').Page) {
const email = unique('owner') + '@example.com';
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('Owner');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
test.describe('dashboard & search', () => {
test('personal dashboard, project dashboard, and search', async ({
page,
}) => {
const projectId = await setupOwner(page);
const keyword = unique('unicorn');
// データ作成: 掲示板スレッド + ToDo
await page.request.post(`/api/projects/${projectId}/board/threads`, {
data: { title: `thread ${keyword}`, bodyMd: 'body', category: 'notice' },
});
const cols = (
(await (
await page.request.get(`/api/projects/${projectId}/todos/columns`)
).json()) as {
columns: { id: number }[];
}
).columns;
await page.request.post(`/api/projects/${projectId}/todos/items`, {
data: {
title: `task ${keyword}`,
columnId: cols[0].id,
assigneeId: undefined,
},
});
// 自分(owner)のIDを取得してToDo担当にする
const me = (await (await page.request.get('/api/auth/me')).json()) as {
user: { id: number };
};
const items = (
(await (
await page.request.get(`/api/projects/${projectId}/todos/items`)
).json()) as {
items: { id: number; title: string }[];
}
).items;
const taskId = items.find((i) => i.title.includes(keyword))!.id;
await page.request.patch(
`/api/projects/${projectId}/todos/items/${taskId}`,
{
data: { assigneeId: me.user.id },
}
);
// 個人ダッシュボード: プロジェクト + 未完了ToDo
await page.goto('/dashboard');
await expect(page.getByText(unique('Proj').split('-')[0])).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
// プロジェクトダッシュボード: 進行中ToDo + 最新掲示板
await page.goto(`/projects/${projectId}`);
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
// 検索
await page.goto(
`/projects/${projectId}/search?q=${encodeURIComponent(keyword)}`
);
await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
});
});

View File

@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { TodoRepository } from '@/repositories/TodoRepository';
import { ChatRepository } from '@/repositories/ChatRepository';
import { BoardRepository } from '@/repositories/BoardRepository';
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { MeetingRepository } from '@/repositories/MeetingRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
import { ActivityLogService } from '@/services/ActivityLogService';
import { ScheduleService } from '@/services/ScheduleService';
import { DashboardService } from '@/services/DashboardService';
import { ForbiddenError } from '@/lib/errors';
function makeServices(db: SqliteDatabase) {
const memberRepo = new ProjectMemberRepository(db);
const scheduleService = new ScheduleService(
new CalendarRepository(db),
new MilestoneRepository(db),
new TodoRepository(db),
memberRepo,
new ActivityLogService(new ActivityLogRepository(db))
);
const dashboard = new DashboardService(
new ProjectRepository(db),
new TodoRepository(db),
new ChatRepository(db),
new BoardRepository(db),
new ProjectNoteRepository(db),
new FileRepository(db),
new MeetingRepository(db),
new ActivityLogRepository(db),
new NotificationRepository(db),
memberRepo,
scheduleService
);
return { dashboard, memberRepo };
}
describe('DashboardService', () => {
let db: SqliteDatabase;
let dashboard: DashboardService;
let projectId: number;
let userId: number;
let outsiderId: number;
beforeEach(() => {
db = createMigratedTestDb();
const ctx = makeServices(db);
dashboard = ctx.dashboard;
const users = new UserRepository(db);
userId = users.create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
outsiderId = users.create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
ctx.memberRepo.add(projectId, userId, 'admin');
});
afterEach(() => db.close());
it('getProjectDashboard aggregates the project resources', () => {
const col = new TodoRepository(db).createColumn({
projectId,
name: 'Todo',
orderIndex: 0,
}).id;
const todoRepo = new TodoRepository(db);
todoRepo.createItem({
projectId,
columnId: col,
title: 't1',
creatorId: userId,
orderIndex: 0,
dueDate: '2099-01-01',
});
todoRepo.createItem({
projectId,
columnId: col,
title: 'done',
creatorId: userId,
orderIndex: 1,
});
todoRepo.updateItem(2, { completedAt: '2099-01-01T00:00:00.000Z' });
new ChatRepository(db).create({ projectId, authorId: userId, body: 'hi' });
new BoardRepository(db).createThread({
projectId,
title: 'thread',
bodyMd: 'b',
authorId: userId,
category: null,
});
new ProjectNoteRepository(db).create({
projectId,
title: 'note',
bodyMd: 'b',
tags: null,
createdById: userId,
});
new FileRepository(db).create({
projectId,
uploaderId: userId,
filename: 'a.bin',
originalName: 'a',
mimeType: 'text/plain',
size: 1,
path: '/tmp/a',
});
new MeetingRepository(db).create({
projectId,
title: 'future',
startAt: '2099-06-15T10:00:00',
endAt: '2099-06-15T11:00:00',
createdById: userId,
});
new MilestoneRepository(db).create({ projectId, title: 'M' });
const d = dashboard.getProjectDashboard(userId, projectId);
expect(d.project.id).toBe(projectId);
expect(d.inProgressTodos).toHaveLength(1); // t1 not completed, 'done' is completed
expect(d.latestChat).toHaveLength(1);
expect(d.latestBoard).toHaveLength(1);
expect(d.latestNotes).toHaveLength(1);
expect(d.recentFiles).toHaveLength(1);
expect(d.nextMeeting?.title).toBe('future');
expect(d.milestones).toHaveLength(1);
});
it('getProjectDashboard forbids a non-member', () => {
expect(() => dashboard.getProjectDashboard(outsiderId, projectId)).toThrow(
ForbiddenError
);
});
it('getPersonalDashboard returns my projects, todos, notifications', () => {
const col = new TodoRepository(db).createColumn({
projectId,
name: 'Todo',
orderIndex: 0,
}).id;
new TodoRepository(db).createItem({
projectId,
columnId: col,
title: 'mine',
creatorId: userId,
assigneeId: userId,
orderIndex: 0,
dueDate: '2099-01-01',
});
new NotificationRepository(db).create({
userId,
projectId,
type: 'mention',
title: 'n',
body: null,
});
const d = dashboard.getPersonalDashboard(userId);
expect(d.projects.map((p) => p.id)).toContain(projectId);
expect(d.incompleteTodos).toHaveLength(1);
expect(d.unreadNotificationCount).toBe(1);
expect(d.overdueTasks).toHaveLength(0); // due 2099, not overdue
});
});

View File

@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { BoardRepository } from '@/repositories/BoardRepository';
import { ChatRepository } from '@/repositories/ChatRepository';
import { TodoRepository } from '@/repositories/TodoRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MeetingRepository } from '@/repositories/MeetingRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
import { SearchService } from '@/services/SearchService';
import { ForbiddenError } from '@/lib/errors';
function makeService(db: SqliteDatabase) {
return new SearchService(
new BoardRepository(db),
new ChatRepository(db),
new TodoRepository(db),
new FileRepository(db),
new CalendarRepository(db),
new MeetingRepository(db),
new MilestoneRepository(db),
new ProjectNoteRepository(db),
new ProjectMemberRepository(db)
);
}
describe('SearchService', () => {
let db: SqliteDatabase;
let service: SearchService;
let projectId: number;
let userId: number;
let outsiderId: number;
beforeEach(() => {
db = createMigratedTestDb();
service = makeService(db);
const users = new UserRepository(db);
userId = users.create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
outsiderId = users.create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
const members = new ProjectMemberRepository(db);
members.add(projectId, userId, 'admin');
});
afterEach(() => db.close());
function seedKeyword(keyword: string) {
const col = new TodoRepository(db).createColumn({
projectId,
name: 'Todo',
orderIndex: 0,
}).id;
new BoardRepository(db).createThread({
projectId,
title: `thread ${keyword}`,
bodyMd: 'body',
authorId: userId,
category: null,
});
new ChatRepository(db).create({
projectId,
authorId: userId,
body: `chat ${keyword}`,
});
new TodoRepository(db).createItem({
projectId,
columnId: col,
title: `task ${keyword}`,
creatorId: userId,
orderIndex: 0,
});
new FileRepository(db).create({
projectId,
uploaderId: userId,
filename: 'a.bin',
originalName: `file-${keyword}.txt`,
mimeType: 'text/plain',
size: 1,
path: '/tmp/a',
});
new CalendarRepository(db).create({
projectId,
title: `event ${keyword}`,
type: 'custom',
startAt: '2026-06-15T10:00:00',
createdById: userId,
});
new MeetingRepository(db).create({
projectId,
title: `meeting ${keyword}`,
startAt: '2026-06-15T10:00:00',
endAt: '2026-06-15T11:00:00',
createdById: userId,
});
new MilestoneRepository(db).create({ projectId, title: `ms ${keyword}` });
new ProjectNoteRepository(db).create({
projectId,
title: `note ${keyword}`,
bodyMd: 'body',
tags: null,
createdById: userId,
});
}
it('returns results across all resource types for a keyword', () => {
seedKeyword('unicorn');
const results = service.search(userId, projectId, { q: 'unicorn' });
const types = new Set(results.map((r) => r.type));
expect(types.has('thread')).toBe(true);
expect(types.has('chat')).toBe(true);
expect(types.has('todo')).toBe(true);
expect(types.has('file')).toBe(true);
expect(types.has('event')).toBe(true);
expect(types.has('meeting')).toBe(true);
expect(types.has('milestone')).toBe(true);
expect(types.has('note')).toBe(true);
});
it('filters by a single type', () => {
seedKeyword('unicorn');
const results = service.search(userId, projectId, {
q: 'unicorn',
type: 'todo',
});
expect(results.every((r) => r.type === 'todo')).toBe(true);
expect(results).toHaveLength(1);
});
it('returns no results for an unmatched keyword', () => {
seedKeyword('unicorn');
expect(service.search(userId, projectId, { q: 'nomatch' })).toEqual([]);
});
it('returns nothing for an empty query', () => {
seedKeyword('unicorn');
expect(service.search(userId, projectId, { q: '' })).toEqual([]);
});
it('forbids a non-member', () => {
seedKeyword('unicorn');
expect(() =>
service.search(outsiderId, projectId, { q: 'unicorn' })
).toThrow(ForbiddenError);
});
});