feat(m5): notifications & activity log foundation

- Expand NotificationRepository (unread list paginated, count, markRead
  ownership-scoped); new ActivityLogRepository (create, findByProject
  paginated) with deterministic created_at+id ordering
- NotificationService (notifyOnEvent, resolveTargets for 8 event types,
  injectable SseBroadcaster for M8) + ActivityLogService (logActivity,
  listByProject)
- NotificationEvent/SseEvent/SseBroadcaster types in lib/types
- APIs: GET /api/notifications, POST /api/notifications/:id/read,
  GET /api/projects/:projectId/activity
- Screens: notifications list + MarkReadButton, NotificationBadge in
  Header, project activity page + ProjectNav link
- Unit tests for both repositories and services (28 new)
This commit is contained in:
Ken Yasue
2026-06-25 01:20:24 +02:00
parent 95722e99f1
commit 3e02feb221
20 changed files with 1197 additions and 3 deletions

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } from '@/lib/api/services';
import { UnauthorizedError, NotFoundError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const { id } = await params;
const service = createNotificationService();
const updated = service.markRead(Number(id), user.id);
if (!updated) {
return handleApiError(new NotFoundError('Notification', id));
}
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } 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) {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const service = createNotificationService();
const result = service.listUnread(user.id, page);
return NextResponse.json(result);
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import {
createProjectService,
createActivityLogService,
} 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;
// メンバーシップ確認(非参加者は403)
const projectService = createProjectService();
try {
projectService.getProject(user.id, Number(projectId));
} catch (error) {
return handleApiError(error);
}
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const activityService = createActivityLogService();
const result = activityService.listByProject(Number(projectId), page);
return NextResponse.json(result);
}

View File

@ -0,0 +1,27 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { createNotificationService } from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { NotificationList } from '@/components/notifications/NotificationList';
export const dynamic = 'force-dynamic';
export default async function NotificationsPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const service = createNotificationService();
const { items } = service.listUnread(user.id, 1);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<main className="mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<NotificationList notifications={items} />
</main>
</div>
);
}

View File

@ -0,0 +1,81 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createProjectService,
createActivityLogService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
const ACTION_LABELS: Record<string, string> = {
todo_created: 'ToDo作成',
todo_updated: 'ToDo更新',
todo_completed: 'ToDo完了',
file_uploaded: 'ファイルアップロード',
board_posted: '掲示板投稿',
comment_added: 'コメント追加',
note_created: 'メモ作成',
note_updated: 'メモ更新',
meeting_created: 'ミーティング作成',
member_added: 'メンバー追加',
milestone_updated: 'マイルストーン更新',
};
export default async function ProjectActivityPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { projectId } = await params;
const projectService = createProjectService();
let project;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const activityService = createActivityLogService();
const { items } = activityService.listByProject(project.id, 1);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="activity" />
<main className="mx-auto max-w-3xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
{items.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<ul className="divide-y rounded-lg border bg-white shadow-sm">
{items.map((log) => (
<li key={log.id} className="p-4">
<div className="flex items-center justify-between">
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs">
{ACTION_LABELS[log.action] ?? log.action}
</span>
<span className="text-xs text-gray-400">{log.createdAt}</span>
</div>
<p className="mt-2 text-sm text-gray-700">
{log.targetType}
{log.targetId !== null ? ` #${log.targetId}` : ''}
</p>
</li>
))}
</ul>
)}
</main>
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
export function Header({ user }: { user: PublicUser }) {
const router = useRouter();
@ -21,6 +22,7 @@ export function Header({ user }: { user: PublicUser }) {
<a href="/dashboard" className="text-gray-600 hover:underline">
</a>
<NotificationBadge />
<a href="/profile" className="text-gray-600 hover:underline">
{user.name}
</a>

View File

@ -1,6 +1,7 @@
const NAV_ITEMS = [
{ href: '', label: '概要' },
{ href: '/members', label: 'メンバー' },
{ href: '/activity', label: 'アクティビティ' },
{ href: '/settings', label: '設定' },
] as const;
@ -13,11 +14,12 @@ export function ProjectNav({
active,
}: {
projectId: number;
active: 'overview' | 'members' | 'settings';
active: 'overview' | 'members' | 'activity' | 'settings';
}) {
const activeMap: Record<string, boolean> = {
overview: active === 'overview',
members: active === 'members',
activity: active === 'activity',
settings: active === 'settings',
};

View File

@ -0,0 +1,31 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function MarkReadButton({ notificationId }: { notificationId: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function onRead() {
setBusy(true);
const res = await fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
});
setBusy(false);
if (res.ok) {
router.refresh();
}
}
return (
<button
type="button"
onClick={onRead}
disabled={busy}
className="text-xs text-gray-500 hover:underline disabled:opacity-50"
>
{busy ? '処理中...' : '既読にする'}
</button>
);
}

View File

@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
export function NotificationBadge() {
const [count, setCount] = useState(0);
useEffect(() => {
let active = true;
fetch('/api/notifications?page=1')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (active && data && typeof data.total === 'number') {
setCount(data.total);
}
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
if (count === 0) {
return (
<a href="/notifications" className="text-gray-600 hover:underline">
</a>
);
}
return (
<a
href="/notifications"
className="relative text-gray-600 hover:underline"
data-notification-count={count}
>
<span className="ml-1 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
{count > 99 ? '99+' : count}
</span>
</a>
);
}

View File

@ -0,0 +1,50 @@
import type { Notification } from '@/lib/types';
import { MarkReadButton } from '@/components/notifications/MarkReadButton';
const TYPE_LABELS: Record<string, string> = {
mention: 'メンション',
todo_assigned: 'ToDo担当',
todo_due_soon: 'ToDo期限',
meeting_invited: 'ミーティング招待',
board_commented: '掲示板コメント',
project_added: 'プロジェクト追加',
file_shared: 'ファイル共有',
note_updated: 'メモ更新',
};
export function NotificationList({
notifications,
}: {
notifications: Notification[];
}) {
if (notifications.length === 0) {
return <p className="text-sm text-gray-400"></p>;
}
return (
<ul className="divide-y rounded-lg border bg-white shadow-sm">
{notifications.map((notification) => (
<li key={notification.id} className="p-4">
<div className="flex items-center justify-between">
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
{TYPE_LABELS[notification.type] ?? notification.type}
</span>
<MarkReadButton notificationId={notification.id} />
</div>
<p className="mt-2 font-medium text-gray-800">{notification.title}</p>
{notification.body && (
<p className="mt-1 text-sm text-gray-600">{notification.body}</p>
)}
{notification.projectId !== null && (
<a
href={`/projects/${notification.projectId}`}
className="mt-1 inline-block text-xs text-blue-600 hover:underline"
>
</a>
)}
</li>
))}
</ul>
);
}

View File

@ -2,8 +2,11 @@ import { getDb } from '@/lib/db/sqlite';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectService } from '@/services/ProjectService';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
/**
* Route Handler用に各Repository/Serviceを構築するファクトリ。
@ -19,6 +22,14 @@ export function createProjectService(): ProjectService {
);
}
export function createNotificationService(): NotificationService {
return new NotificationService(new NotificationRepository(getDb()));
}
export function createActivityLogService(): ActivityLogService {
return new ActivityLogService(new ActivityLogRepository(getDb()));
}
export function createUserRepository(): UserRepository {
return new UserRepository(getDb());
}

View File

@ -239,3 +239,56 @@ export interface SchemaMigration {
filename: string;
appliedAt: string;
}
// ===== 通知イベント =====
export interface NotificationEventBase {
projectId: number | null;
title: string;
body: string | null;
}
/**
* 通知イベントの判別共用体。
* resolveTargets で type ごとに対象ユーザーを解決する。
*/
export type NotificationEvent =
| (NotificationEventBase & {
type: 'mention';
mentionedUserId: number;
})
| (NotificationEventBase & { type: 'todo_assigned'; assigneeId: number })
| (NotificationEventBase & { type: 'todo_due_soon'; assigneeId: number })
| (NotificationEventBase & {
type: 'meeting_invited';
memberIds: number[];
})
| (NotificationEventBase & {
type: 'board_commented';
threadAuthorId: number;
})
| (NotificationEventBase & { type: 'project_added'; addedUserId: number })
| (NotificationEventBase & {
type: 'file_shared';
projectMemberIds: number[];
})
| (NotificationEventBase & {
type: 'note_updated';
projectMemberIds: number[];
});
/** SSE配信イベント(M8でSseHubが扱う)。M5ではbroadcasterの型として使用 */
export type SseEvent =
| { type: 'notification.created'; data: { projectId: number | null } }
| { type: 'chat.message.created'; data: { projectId: number } }
| { type: 'chat.message.updated'; data: { projectId: number } }
| { type: 'chat.message.deleted'; data: { projectId: number; id: number } }
| { type: 'todo.updated'; data: { projectId: number } }
| { type: 'file.uploaded'; data: { projectId: number } }
| { type: 'meeting.created'; data: { projectId: number } }
| { type: 'note.updated'; data: { projectId: number } };
/** SSE配信を行うコンポーネントの抽象(M8のSseHubが実装) */
export interface SseBroadcaster {
broadcast(projectId: number, event: SseEvent): void;
}

View File

@ -0,0 +1,93 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { ActivityLog } from '@/lib/types';
import type { Paginated } from '@/repositories/NotificationRepository';
const DEFAULT_PAGE_SIZE = 20;
export interface CreateActivityLogInput {
projectId: number;
actorId: number;
action: string;
targetType: string;
targetId: number | null;
metadataJson: string | null;
}
interface ActivityLogRow {
id: number;
project_id: number;
actor_id: number;
action: string;
target_type: string;
target_id: number | null;
metadata_json: string | null;
created_at: string;
}
function mapActivityLog(row: ActivityLogRow): ActivityLog {
return {
id: row.id,
projectId: row.project_id,
actorId: row.actor_id,
action: row.action,
targetType: row.target_type,
targetId: row.target_id,
metadataJson: row.metadata_json,
createdAt: row.created_at,
};
}
/**
* activity_logsテーブルへのデータアクセスを担うRepository。
*/
export class ActivityLogRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateActivityLogInput): ActivityLog {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO activity_logs (project_id, actor_id, action, target_type, target_id, metadata_json, created_at)
VALUES (@projectId, @actorId, @action, @targetType, @targetId, @metadataJson, @createdAt)`,
{
projectId: input.projectId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadataJson: input.metadataJson,
createdAt: now,
}
);
return {
id: Number(result.lastInsertRowid),
projectId: input.projectId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadataJson: input.metadataJson,
createdAt: now,
};
}
/** プロジェクト別のアクティビティログ一覧(作成日時降順・ページネーション) */
findByProject(
projectId: number,
page: number = 1,
pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<ActivityLog> {
const offset = (page - 1) * pageSize;
const items = this.db.query<ActivityLogRow>(
`SELECT * FROM activity_logs
WHERE project_id = @projectId
ORDER BY created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`,
{ projectId, pageSize, offset }
);
const row = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM activity_logs WHERE project_id = @projectId',
{ projectId }
);
return { items: items.map(mapActivityLog), total: row?.count ?? 0 };
}
}

View File

@ -9,10 +9,39 @@ export interface CreateNotificationInput {
body: string | null;
}
interface NotificationRow {
id: number;
user_id: number;
project_id: number | null;
type: string;
title: string;
body: string | null;
read_at: string | null;
created_at: string;
}
function mapNotification(row: NotificationRow): Notification {
return {
id: row.id,
userId: row.user_id,
projectId: row.project_id,
type: row.type as NotificationType,
title: row.title,
body: row.body,
readAt: row.read_at,
createdAt: row.created_at,
};
}
export interface Paginated<T> {
items: T[];
total: number;
}
export const DEFAULT_PAGE_SIZE = 20;
/**
* notificationsテーブルへのデータアクセスを担うRepository。
* M4ではメンバー追加通知のために create のみ実装する。
* 一覧取得・既読化は M5 で拡張する。
*/
export class NotificationRepository {
constructor(private readonly db: SqliteDatabase) {}
@ -42,4 +71,41 @@ export class NotificationRepository {
createdAt: now,
};
}
/** ユーザーの未読通知一覧(作成日時降順・ページネーション) */
findUnreadByUser(
userId: number,
page: number = 1,
pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<Notification> {
const offset = (page - 1) * pageSize;
const items = this.db.query<NotificationRow>(
`SELECT * FROM notifications
WHERE user_id = @userId AND read_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`,
{ userId, pageSize, offset }
);
const total = this.countUnreadByUser(userId);
return { items: items.map(mapNotification), total };
}
countUnreadByUser(userId: number): number {
const row = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM notifications WHERE user_id = @userId AND read_at IS NULL',
{ userId }
);
return row?.count ?? 0;
}
/** 通知を既読にする(本人所有の未読通知のみ) */
markRead(id: number, userId: number): boolean {
const now = new Date().toISOString();
const result = this.db.execute(
`UPDATE notifications SET read_at = @now
WHERE id = @id AND user_id = @userId AND read_at IS NULL`,
{ now, id, userId }
);
return result.changes > 0;
}
}

View File

@ -0,0 +1,35 @@
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import type { ActivityLog } from '@/lib/types';
import type { Paginated } from '@/repositories/NotificationRepository';
export interface LogActivityInput {
projectId: number;
actorId: number;
action: string;
targetType: string;
targetId: number | null;
metadata?: Record<string, unknown> | null;
}
/**
* アクティビティログ記録を担うService。
* 変更操作をプロジェクト単位の時系列ログとして記録する。
*/
export class ActivityLogService {
constructor(private readonly activityLogRepository: ActivityLogRepository) {}
logActivity(input: LogActivityInput): ActivityLog {
return this.activityLogRepository.create({
projectId: input.projectId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
metadataJson: input.metadata ? JSON.stringify(input.metadata) : null,
});
}
listByProject(projectId: number, page: number = 1): Paginated<ActivityLog> {
return this.activityLogRepository.findByProject(projectId, page);
}
}

View File

@ -0,0 +1,84 @@
import { NotificationRepository } from '@/repositories/NotificationRepository';
import type { SseBroadcaster } from '@/lib/types';
import type {
Notification,
NotificationEvent,
NotificationType,
} from '@/lib/types';
import type { Paginated } from '@/repositories/NotificationRepository';
/**
* 通知生成を担うService。
* イベント種別に応じた対象ユーザー解決(resolveTargets)と通知作成を行う。
* SSE配信は注入可能なbroadcaster経由(M8でSseHubを注入。M5では省略可能)。
*/
export class NotificationService {
constructor(
private readonly notificationRepository: NotificationRepository,
private readonly broadcaster?: SseBroadcaster | null
) {}
/** イベント発生時に通知を作成し、SSE配信する */
notifyOnEvent(event: NotificationEvent): Notification[] {
const targetUserIds = this.resolveTargets(event);
const created: Notification[] = [];
for (const userId of targetUserIds) {
const notification = this.notificationRepository.create({
userId,
projectId: event.projectId,
type: event.type,
title: event.title,
body: event.body,
});
created.push(notification);
}
if (event.projectId !== null && this.broadcaster) {
this.broadcaster.broadcast(event.projectId, {
type: 'notification.created',
data: { projectId: event.projectId },
});
}
return created;
}
/** イベント種別から対象ユーザーIDの集合を解決する */
resolveTargets(event: NotificationEvent): number[] {
switch (event.type) {
case 'mention':
return [event.mentionedUserId];
case 'todo_assigned':
return [event.assigneeId];
case 'todo_due_soon':
return [event.assigneeId];
case 'meeting_invited':
return event.memberIds;
case 'board_commented':
return [event.threadAuthorId];
case 'project_added':
return [event.addedUserId];
case 'file_shared':
return event.projectMemberIds;
case 'note_updated':
return event.projectMemberIds;
}
}
listUnread(userId: number, page: number = 1): Paginated<Notification> {
return this.notificationRepository.findUnreadByUser(userId, page);
}
countUnread(userId: number): number {
return this.notificationRepository.countUnreadByUser(userId);
}
markRead(id: number, userId: number): boolean {
return this.notificationRepository.markRead(id, userId);
}
}
/** 型安全性のためのヘルパー: NotificationEvent.type → NotificationType */
export function eventTypeToType(
type: NotificationEvent['type']
): NotificationType {
return type;
}

View File

@ -0,0 +1,114 @@
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 { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
describe('ActivityLogRepository', () => {
let db: SqliteDatabase;
let repo: ActivityLogRepository;
let userId: number;
let projectId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new ActivityLogRepository(db);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
});
afterEach(() => db.close());
it('creates an activity log entry', () => {
const log = repo.create({
projectId,
actorId: userId,
action: 'todo_created',
targetType: 'todo',
targetId: 1,
metadataJson: JSON.stringify({ title: 'T' }),
});
expect(log.id).toBeGreaterThan(0);
expect(log.action).toBe('todo_created');
expect(log.projectId).toBe(projectId);
});
it('lists logs for a project (newest first) with total', () => {
repo.create({
projectId,
actorId: userId,
action: 'a',
targetType: 't',
targetId: 1,
metadataJson: null,
});
repo.create({
projectId,
actorId: userId,
action: 'b',
targetType: 't',
targetId: 2,
metadataJson: null,
});
const result = repo.findByProject(projectId, 1, 20);
expect(result.total).toBe(2);
expect(result.items[0].action).toBe('b');
});
it('isolates logs per project', () => {
const p2 = new ProjectRepository(db).create({
name: 'P2',
ownerId: userId,
}).id;
repo.create({
projectId,
actorId: userId,
action: 'a',
targetType: 't',
targetId: 1,
metadataJson: null,
});
repo.create({
projectId: p2,
actorId: userId,
action: 'b',
targetType: 't',
targetId: 2,
metadataJson: null,
});
expect(repo.findByProject(projectId).total).toBe(1);
expect(repo.findByProject(p2).total).toBe(1);
});
it('paginates results', () => {
for (let i = 0; i < 5; i++) {
repo.create({
projectId,
actorId: userId,
action: 'a',
targetType: 't',
targetId: i,
metadataJson: null,
});
}
const page1 = repo.findByProject(projectId, 1, 2);
const page2 = repo.findByProject(projectId, 2, 2);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
expect(page1.total).toBe(5);
});
});

View File

@ -0,0 +1,165 @@
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 { NotificationRepository } from '@/repositories/NotificationRepository';
describe('NotificationRepository', () => {
let db: SqliteDatabase;
let repo: NotificationRepository;
let userId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new NotificationRepository(db);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
});
afterEach(() => db.close());
it('creates a notification as unread', () => {
const n = repo.create({
userId,
projectId: null,
type: 'mention',
title: 't',
body: 'b',
});
expect(n.id).toBeGreaterThan(0);
expect(n.readAt).toBeNull();
expect(n.userId).toBe(userId);
});
it('lists unread notifications for the user (newest first) with total', () => {
repo.create({
userId,
projectId: null,
type: 'mention',
title: '1',
body: null,
});
repo.create({
userId,
projectId: null,
type: 'mention',
title: '2',
body: null,
});
const result = repo.findUnreadByUser(userId, 1, 20);
expect(result.total).toBe(2);
expect(result.items).toHaveLength(2);
expect(result.items[0].title).toBe('2');
});
it('excludes already-read notifications', () => {
const n = repo.create({
userId,
projectId: null,
type: 'mention',
title: 'x',
body: null,
});
repo.markRead(n.id, userId);
expect(repo.findUnreadByUser(userId).total).toBe(0);
});
it('isolates notifications per user', () => {
const other = new UserRepository(db).create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
repo.create({
userId,
projectId: null,
type: 'mention',
title: 'mine',
body: null,
});
repo.create({
userId: other,
projectId: null,
type: 'mention',
title: 'theirs',
body: null,
});
expect(repo.findUnreadByUser(userId).total).toBe(1);
expect(repo.findUnreadByUser(other).total).toBe(1);
});
it('paginates results', () => {
for (let i = 0; i < 5; i++) {
repo.create({
userId,
projectId: null,
type: 'mention',
title: `t${i}`,
body: null,
});
}
const page1 = repo.findUnreadByUser(userId, 1, 2);
const page2 = repo.findUnreadByUser(userId, 2, 2);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
expect(page1.total).toBe(5);
});
it('countUnreadByUser returns the unread count', () => {
repo.create({
userId,
projectId: null,
type: 'mention',
title: 'a',
body: null,
});
repo.create({
userId,
projectId: null,
type: 'mention',
title: 'b',
body: null,
});
expect(repo.countUnreadByUser(userId)).toBe(2);
});
it('markRead only affects the owner unread notification and returns true', () => {
const n = repo.create({
userId,
projectId: null,
type: 'mention',
title: 'a',
body: null,
});
expect(repo.markRead(n.id, userId)).toBe(true);
// 二回目は既読済みなので false
expect(repo.markRead(n.id, userId)).toBe(false);
});
it('markRead rejects other users (ownership scope)', () => {
const other = new UserRepository(db).create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
const n = repo.create({
userId,
projectId: null,
type: 'mention',
title: 'a',
body: null,
});
expect(repo.markRead(n.id, other)).toBe(false);
expect(repo.countUnreadByUser(userId)).toBe(1);
});
});

View File

@ -0,0 +1,104 @@
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 { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { ActivityLogService } from '@/services/ActivityLogService';
describe('ActivityLogService', () => {
let db: SqliteDatabase;
let repo: ActivityLogRepository;
let service: ActivityLogService;
let userId: number;
let projectId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new ActivityLogRepository(db);
service = new ActivityLogService(repo);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
});
afterEach(() => db.close());
it('logActivity creates a log entry with serialized metadata', () => {
const log = service.logActivity({
projectId,
actorId: userId,
action: 'todo_created',
targetType: 'todo',
targetId: 1,
metadata: { title: 'T' },
});
expect(log.action).toBe('todo_created');
expect(log.metadataJson).toBe(JSON.stringify({ title: 'T' }));
});
it('logActivity stores null metadata when not provided', () => {
const log = service.logActivity({
projectId,
actorId: userId,
action: 'member_added',
targetType: 'member',
targetId: 2,
});
expect(log.metadataJson).toBeNull();
});
it('listByProject returns paginated logs for the project', () => {
service.logActivity({
projectId,
actorId: userId,
action: 'a',
targetType: 't',
targetId: 1,
});
service.logActivity({
projectId,
actorId: userId,
action: 'b',
targetType: 't',
targetId: 2,
});
const result = service.listByProject(projectId, 1);
expect(result.total).toBe(2);
expect(result.items[0].action).toBe('b');
});
it('listByProject isolates by project', () => {
const p2 = new ProjectRepository(db).create({
name: 'P2',
ownerId: userId,
}).id;
service.logActivity({
projectId,
actorId: userId,
action: 'a',
targetType: 't',
targetId: 1,
});
service.logActivity({
projectId: p2,
actorId: userId,
action: 'b',
targetType: 't',
targetId: 2,
});
expect(service.listByProject(projectId).total).toBe(1);
expect(service.listByProject(p2).total).toBe(1);
});
});

View File

@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach, vi } 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 { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService';
import type { NotificationEvent, SseBroadcaster } from '@/lib/types';
describe('NotificationService', () => {
let db: SqliteDatabase;
let repo: NotificationRepository;
let service: NotificationService;
let userId: number;
let projectId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new NotificationRepository(db);
service = new NotificationService(repo);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
});
afterEach(() => db.close());
function makeEvent(
type: NotificationEvent['type'],
extra: Record<string, unknown>
): NotificationEvent {
return {
type,
projectId,
title: 'title',
body: 'body',
...extra,
} as NotificationEvent;
}
describe('resolveTargets', () => {
it('mention → mentionedUserId', () => {
expect(
service.resolveTargets(makeEvent('mention', { mentionedUserId: 7 }))
).toEqual([7]);
});
it('todo_assigned → assigneeId', () => {
expect(
service.resolveTargets(makeEvent('todo_assigned', { assigneeId: 7 }))
).toEqual([7]);
});
it('todo_due_soon → assigneeId', () => {
expect(
service.resolveTargets(makeEvent('todo_due_soon', { assigneeId: 7 }))
).toEqual([7]);
});
it('meeting_invited → memberIds', () => {
expect(
service.resolveTargets(
makeEvent('meeting_invited', { memberIds: [1, 2, 3] })
)
).toEqual([1, 2, 3]);
});
it('board_commented → threadAuthorId', () => {
expect(
service.resolveTargets(
makeEvent('board_commented', { threadAuthorId: 9 })
)
).toEqual([9]);
});
it('project_added → addedUserId', () => {
expect(
service.resolveTargets(makeEvent('project_added', { addedUserId: 5 }))
).toEqual([5]);
});
it('file_shared → projectMemberIds', () => {
expect(
service.resolveTargets(
makeEvent('file_shared', { projectMemberIds: [1, 2] })
)
).toEqual([1, 2]);
});
it('note_updated → projectMemberIds', () => {
expect(
service.resolveTargets(
makeEvent('note_updated', { projectMemberIds: [3, 4] })
)
).toEqual([3, 4]);
});
});
it('notifyOnEvent creates a notification per target user', () => {
const other = new UserRepository(db).create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
const created = service.notifyOnEvent(
makeEvent('meeting_invited', { memberIds: [userId, other] })
);
expect(created).toHaveLength(2);
expect(repo.countUnreadByUser(userId)).toBe(1);
expect(repo.countUnreadByUser(other)).toBe(1);
});
it('notifyOnEvent broadcasts notification.created when a broadcaster is injected', () => {
const broadcast = vi.fn();
const broadcaster: SseBroadcaster = { broadcast };
const withBroadcaster = new NotificationService(repo, broadcaster);
withBroadcaster.notifyOnEvent(
makeEvent('project_added', { addedUserId: userId })
);
expect(broadcast).toHaveBeenCalledTimes(1);
expect(broadcast).toHaveBeenCalledWith(
1,
expect.objectContaining({ type: 'notification.created' })
);
});
it('notifyOnEvent does not broadcast when projectId is null', () => {
const broadcast = vi.fn();
const withBroadcaster = new NotificationService(repo, { broadcast });
const event: NotificationEvent = {
type: 'mention',
projectId: null,
title: 't',
body: null,
mentionedUserId: userId,
};
withBroadcaster.notifyOnEvent(event);
expect(broadcast).not.toHaveBeenCalled();
});
it('listUnread / countUnread / markRead delegate to the repository', () => {
const n = service.notifyOnEvent(
makeEvent('mention', { mentionedUserId: userId })
)[0];
expect(service.countUnread(userId)).toBe(1);
expect(service.listUnread(userId).total).toBe(1);
expect(service.markRead(n.id, userId)).toBe(true);
expect(service.countUnread(userId)).toBe(0);
});
});