From 3e02feb2217d735bab967311b1f3ae250bdebf47 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 01:20:24 +0200 Subject: [PATCH] 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) --- app/api/notifications/[id]/read/route.ts | 25 +++ app/api/notifications/route.ts | 19 ++ .../projects/[projectId]/activity/route.ts | 34 ++++ app/notifications/page.tsx | 27 +++ app/projects/[projectId]/activity/page.tsx | 81 +++++++++ components/layout/Header.tsx | 2 + components/layout/ProjectNav.tsx | 4 +- components/notifications/MarkReadButton.tsx | 31 ++++ .../notifications/NotificationBadge.tsx | 43 +++++ components/notifications/NotificationList.tsx | 50 ++++++ lib/api/services.ts | 11 ++ lib/types/index.ts | 53 ++++++ repositories/ActivityLogRepository.ts | 93 ++++++++++ repositories/NotificationRepository.ts | 70 +++++++- services/ActivityLogService.ts | 35 ++++ services/NotificationService.ts | 84 +++++++++ .../ActivityLogRepository.test.ts | 114 ++++++++++++ .../NotificationRepository.test.ts | 165 ++++++++++++++++++ .../unit/services/ActivityLogService.test.ts | 104 +++++++++++ .../unit/services/NotificationService.test.ts | 155 ++++++++++++++++ 20 files changed, 1197 insertions(+), 3 deletions(-) create mode 100644 app/api/notifications/[id]/read/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/projects/[projectId]/activity/route.ts create mode 100644 app/notifications/page.tsx create mode 100644 app/projects/[projectId]/activity/page.tsx create mode 100644 components/notifications/MarkReadButton.tsx create mode 100644 components/notifications/NotificationBadge.tsx create mode 100644 components/notifications/NotificationList.tsx create mode 100644 repositories/ActivityLogRepository.ts create mode 100644 services/ActivityLogService.ts create mode 100644 services/NotificationService.ts create mode 100644 tests/unit/repositories/ActivityLogRepository.test.ts create mode 100644 tests/unit/repositories/NotificationRepository.test.ts create mode 100644 tests/unit/services/ActivityLogService.test.ts create mode 100644 tests/unit/services/NotificationService.test.ts diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..7218576 --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -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 }); +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..6d24794 --- /dev/null +++ b/app/api/notifications/route.ts @@ -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); +} diff --git a/app/api/projects/[projectId]/activity/route.ts b/app/api/projects/[projectId]/activity/route.ts new file mode 100644 index 0000000..ec5c2d7 --- /dev/null +++ b/app/api/projects/[projectId]/activity/route.ts @@ -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); +} diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..bf57c68 --- /dev/null +++ b/app/notifications/page.tsx @@ -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 ( +
+
+
+

通知

+ +
+
+ ); +} diff --git a/app/projects/[projectId]/activity/page.tsx b/app/projects/[projectId]/activity/page.tsx new file mode 100644 index 0000000..4b2cf38 --- /dev/null +++ b/app/projects/[projectId]/activity/page.tsx @@ -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 = { + 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 ( +
+
+ +
+

アクティビティログ

+ {items.length === 0 ? ( +

アクティビティはありません。

+ ) : ( +
    + {items.map((log) => ( +
  • +
    + + {ACTION_LABELS[log.action] ?? log.action} + + {log.createdAt} +
    +

    + {log.targetType} + {log.targetId !== null ? ` #${log.targetId}` : ''} +

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index c99bf34..e84c126 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -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 }) { ダッシュボード + {user.name} さん diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index 9ceeaaa..396c252 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -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 = { overview: active === 'overview', members: active === 'members', + activity: active === 'activity', settings: active === 'settings', }; diff --git a/components/notifications/MarkReadButton.tsx b/components/notifications/MarkReadButton.tsx new file mode 100644 index 0000000..cf2d609 --- /dev/null +++ b/components/notifications/MarkReadButton.tsx @@ -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 ( + + ); +} diff --git a/components/notifications/NotificationBadge.tsx b/components/notifications/NotificationBadge.tsx new file mode 100644 index 0000000..e03d42c --- /dev/null +++ b/components/notifications/NotificationBadge.tsx @@ -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 ( + + 通知 + + ); + } + + return ( + + 通知 + + {count > 99 ? '99+' : count} + + + ); +} diff --git a/components/notifications/NotificationList.tsx b/components/notifications/NotificationList.tsx new file mode 100644 index 0000000..6ca12dd --- /dev/null +++ b/components/notifications/NotificationList.tsx @@ -0,0 +1,50 @@ +import type { Notification } from '@/lib/types'; +import { MarkReadButton } from '@/components/notifications/MarkReadButton'; + +const TYPE_LABELS: Record = { + 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

未読の通知はありません。

; + } + + return ( +
    + {notifications.map((notification) => ( +
  • +
    + + {TYPE_LABELS[notification.type] ?? notification.type} + + +
    +

    {notification.title}

    + {notification.body && ( +

    {notification.body}

    + )} + {notification.projectId !== null && ( + + プロジェクトを開く + + )} +
  • + ))} +
+ ); +} diff --git a/lib/api/services.ts b/lib/api/services.ts index 6e997a9..c86ff88 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -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()); } diff --git a/lib/types/index.ts b/lib/types/index.ts index bff64f4..ca5fd3d 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -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; +} diff --git a/repositories/ActivityLogRepository.ts b/repositories/ActivityLogRepository.ts new file mode 100644 index 0000000..07685b8 --- /dev/null +++ b/repositories/ActivityLogRepository.ts @@ -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 { + const offset = (page - 1) * pageSize; + const items = this.db.query( + `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 }; + } +} diff --git a/repositories/NotificationRepository.ts b/repositories/NotificationRepository.ts index 3ba8076..b3910fe 100644 --- a/repositories/NotificationRepository.ts +++ b/repositories/NotificationRepository.ts @@ -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 { + 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 { + const offset = (page - 1) * pageSize; + const items = this.db.query( + `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; + } } diff --git a/services/ActivityLogService.ts b/services/ActivityLogService.ts new file mode 100644 index 0000000..b82a673 --- /dev/null +++ b/services/ActivityLogService.ts @@ -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 | 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 { + return this.activityLogRepository.findByProject(projectId, page); + } +} diff --git a/services/NotificationService.ts b/services/NotificationService.ts new file mode 100644 index 0000000..3437e28 --- /dev/null +++ b/services/NotificationService.ts @@ -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 { + 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; +} diff --git a/tests/unit/repositories/ActivityLogRepository.test.ts b/tests/unit/repositories/ActivityLogRepository.test.ts new file mode 100644 index 0000000..290a272 --- /dev/null +++ b/tests/unit/repositories/ActivityLogRepository.test.ts @@ -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); + }); +}); diff --git a/tests/unit/repositories/NotificationRepository.test.ts b/tests/unit/repositories/NotificationRepository.test.ts new file mode 100644 index 0000000..1531b84 --- /dev/null +++ b/tests/unit/repositories/NotificationRepository.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/ActivityLogService.test.ts b/tests/unit/services/ActivityLogService.test.ts new file mode 100644 index 0000000..bde1144 --- /dev/null +++ b/tests/unit/services/ActivityLogService.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/NotificationService.test.ts b/tests/unit/services/NotificationService.test.ts new file mode 100644 index 0000000..6b3121d --- /dev/null +++ b/tests/unit/services/NotificationService.test.ts @@ -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 + ): 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); + }); +});