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 ? (
+ アクティビティはありません。
+ ) : (
+
+ )}
+
+
+ );
+}
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 (
+
+ );
+}
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);
+ });
+});