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:
25
app/api/notifications/[id]/read/route.ts
Normal file
25
app/api/notifications/[id]/read/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
19
app/api/notifications/route.ts
Normal file
19
app/api/notifications/route.ts
Normal 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);
|
||||||
|
}
|
||||||
34
app/api/projects/[projectId]/activity/route.ts
Normal file
34
app/api/projects/[projectId]/activity/route.ts
Normal 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);
|
||||||
|
}
|
||||||
27
app/notifications/page.tsx
Normal file
27
app/notifications/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/projects/[projectId]/activity/page.tsx
Normal file
81
app/projects/[projectId]/activity/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
import type { PublicUser } from '@/lib/auth/getCurrentUser';
|
||||||
|
import { NotificationBadge } from '@/components/notifications/NotificationBadge';
|
||||||
|
|
||||||
export function Header({ user }: { user: PublicUser }) {
|
export function Header({ user }: { user: PublicUser }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -21,6 +22,7 @@ export function Header({ user }: { user: PublicUser }) {
|
|||||||
<a href="/dashboard" className="text-gray-600 hover:underline">
|
<a href="/dashboard" className="text-gray-600 hover:underline">
|
||||||
ダッシュボード
|
ダッシュボード
|
||||||
</a>
|
</a>
|
||||||
|
<NotificationBadge />
|
||||||
<a href="/profile" className="text-gray-600 hover:underline">
|
<a href="/profile" className="text-gray-600 hover:underline">
|
||||||
{user.name} さん
|
{user.name} さん
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '', label: '概要' },
|
{ href: '', label: '概要' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -13,11 +14,12 @@ export function ProjectNav({
|
|||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
projectId: number;
|
projectId: number;
|
||||||
active: 'overview' | 'members' | 'settings';
|
active: 'overview' | 'members' | 'activity' | 'settings';
|
||||||
}) {
|
}) {
|
||||||
const activeMap: Record<string, boolean> = {
|
const activeMap: Record<string, boolean> = {
|
||||||
overview: active === 'overview',
|
overview: active === 'overview',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
31
components/notifications/MarkReadButton.tsx
Normal file
31
components/notifications/MarkReadButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/notifications/NotificationBadge.tsx
Normal file
43
components/notifications/NotificationBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
components/notifications/NotificationList.tsx
Normal file
50
components/notifications/NotificationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,8 +2,11 @@ import { getDb } from '@/lib/db/sqlite';
|
|||||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||||
|
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||||
import { UserRepository } from '@/repositories/UserRepository';
|
import { UserRepository } from '@/repositories/UserRepository';
|
||||||
import { ProjectService } from '@/services/ProjectService';
|
import { ProjectService } from '@/services/ProjectService';
|
||||||
|
import { NotificationService } from '@/services/NotificationService';
|
||||||
|
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* 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 {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
return new UserRepository(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,3 +239,56 @@ export interface SchemaMigration {
|
|||||||
filename: string;
|
filename: string;
|
||||||
appliedAt: 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;
|
||||||
|
}
|
||||||
|
|||||||
93
repositories/ActivityLogRepository.ts
Normal file
93
repositories/ActivityLogRepository.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,10 +9,39 @@ export interface CreateNotificationInput {
|
|||||||
body: string | null;
|
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。
|
* notificationsテーブルへのデータアクセスを担うRepository。
|
||||||
* M4ではメンバー追加通知のために create のみ実装する。
|
|
||||||
* 一覧取得・既読化は M5 で拡張する。
|
|
||||||
*/
|
*/
|
||||||
export class NotificationRepository {
|
export class NotificationRepository {
|
||||||
constructor(private readonly db: SqliteDatabase) {}
|
constructor(private readonly db: SqliteDatabase) {}
|
||||||
@ -42,4 +71,41 @@ export class NotificationRepository {
|
|||||||
createdAt: now,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
services/ActivityLogService.ts
Normal file
35
services/ActivityLogService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
services/NotificationService.ts
Normal file
84
services/NotificationService.ts
Normal 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;
|
||||||
|
}
|
||||||
114
tests/unit/repositories/ActivityLogRepository.test.ts
Normal file
114
tests/unit/repositories/ActivityLogRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
165
tests/unit/repositories/NotificationRepository.test.ts
Normal file
165
tests/unit/repositories/NotificationRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
104
tests/unit/services/ActivityLogService.test.ts
Normal file
104
tests/unit/services/ActivityLogService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
tests/unit/services/NotificationService.test.ts
Normal file
155
tests/unit/services/NotificationService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user