feat(m5): notifications & activity log foundation
- Expand NotificationRepository (unread list paginated, count, markRead ownership-scoped); new ActivityLogRepository (create, findByProject paginated) with deterministic created_at+id ordering - NotificationService (notifyOnEvent, resolveTargets for 8 event types, injectable SseBroadcaster for M8) + ActivityLogService (logActivity, listByProject) - NotificationEvent/SseEvent/SseBroadcaster types in lib/types - APIs: GET /api/notifications, POST /api/notifications/:id/read, GET /api/projects/:projectId/activity - Screens: notifications list + MarkReadButton, NotificationBadge in Header, project activity page + ProjectNav link - Unit tests for both repositories and services (28 new)
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user