Merge branch 'feature/m5-notifications-activity' - M5 notifications & activity log
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 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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
|
||||
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 { 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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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