diff options
Diffstat (limited to 'lib/notification')
| -rw-r--r-- | lib/notification/NotificationContext.tsx | 219 | ||||
| -rw-r--r-- | lib/notification/service.ts | 342 |
2 files changed, 561 insertions, 0 deletions
diff --git a/lib/notification/NotificationContext.tsx b/lib/notification/NotificationContext.tsx new file mode 100644 index 00000000..b1779264 --- /dev/null +++ b/lib/notification/NotificationContext.tsx @@ -0,0 +1,219 @@ +// lib/notification/NotificationContext.tsx +"use client"; + +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { toast } from 'sonner'; // 또는 react-hot-toast + +interface Notification { + id: string; + title: string; + message: string; + type: string; + relatedRecordId?: string; + relatedRecordType?: string; + isRead: boolean; + createdAt: string; +} + +interface NotificationContextType { + notifications: Notification[]; + unreadCount: number; + markAsRead: (id: string) => Promise<void>; + markAllAsRead: () => Promise<void>; + refreshNotifications: () => Promise<void>; +} + +const NotificationContext = createContext<NotificationContextType | null>(null); + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const { data: session } = useSession(); + const [notifications, setNotifications] = useState<Notification[]>([]); + const [unreadCount, setUnreadCount] = useState(0); + + // 알림 목록 가져오기 + const refreshNotifications = async () => { + if (!session?.user) return; + + try { + const response = await fetch('/api/notifications'); + const data = await response.json(); + setNotifications(data.notifications); + setUnreadCount(data.unreadCount); + } catch (error) { + console.error('Failed to fetch notifications:', error); + } + }; + + // 읽음 처리 + const markAsRead = async (id: string) => { + try { + await fetch(`/api/notifications/${id}/read`, { method: 'PATCH' }); + setNotifications(prev => + prev.map(n => n.id === id ? { ...n, isRead: true } : n) + ); + setUnreadCount(prev => prev - 1); + } catch (error) { + console.error('Failed to mark notification as read:', error); + } + }; + + // 모든 알림 읽음 처리 + const markAllAsRead = async () => { + try { + await fetch('/api/notifications/read-all', { method: 'PATCH' }); + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + setUnreadCount(0); + } catch (error) { + console.error('Failed to mark all notifications as read:', error); + } + }; + + // SSE 연결로 실시간 알림 수신 + useEffect(() => { + if (!session?.user) return; + + // 초기 알림 로드 + refreshNotifications(); + + // SSE 연결 + const eventSource = new EventSource('/api/notifications/stream'); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'heartbeat' || data.type === 'connected') { + return; // 하트비트 및 연결 확인 메시지는 무시 + } + + if (data.type === 'new_notification') { + const newNotification = data.data; + + // 새 알림을 기존 목록에 추가 + setNotifications(prev => [newNotification, ...prev]); + setUnreadCount(prev => prev + 1); + + // Toast 알림 표시 + toast(newNotification.title, { + description: newNotification.message, + action: { + label: "보기", + onClick: () => { + handleNotificationClick(newNotification); + }, + }, + }); + } else if (data.type === 'notification_read') { + const readData = data.data; + + // 읽음 상태 업데이트 + setNotifications(prev => + prev.map(n => n.id === readData.id ? { ...n, isRead: true, readAt: readData.read_at } : n) + ); + + if (!readData.is_read) { + setUnreadCount(prev => Math.max(0, prev - 1)); + } + } + } catch (error) { + console.error('Failed to parse SSE data:', error); + } + }; + + eventSource.onopen = () => { + console.log('SSE connection established'); + }; + + eventSource.onerror = (error) => { + console.error('SSE error:', error); + // 자동 재연결은 EventSource가 처리 + }; + + return () => { + eventSource.close(); + }; + }, [session?.user]); + + const handleNotificationClick = (notification: Notification) => { + // 알림 클릭 시 관련 페이지로 이동 + if (notification.relatedRecordId && notification.relatedRecordType) { + const url = getNotificationUrl(notification.relatedRecordType, notification.relatedRecordId); + window.location.href = url; + } + markAsRead(notification.id); + }; + + const getNotificationUrl = (type: string, id: string | null) => { + const pathParts = window.location.pathname.split('/'); + const lng = pathParts[1]; + const domain = pathParts[2]; + + // 도메인별 기본 URL 설정 + const baseUrls = { + 'partners': `/${lng}/partners`, + 'procurement': `/${lng}/procurement`, + 'sales': `/${lng}/sales`, + 'engineering': `/${lng}/engineering`, + 'evcp': `/${lng}/evcp` + }; + + const baseUrl = baseUrls[domain as keyof typeof baseUrls] || `/${lng}/evcp`; + + if (!id) { + // ID가 없는 경우 (시스템 공지, 일반 알림 등) + switch (type) { + case 'evaluation': + case 'evaluation_request': + return domain === 'partners' ? `${baseUrl}/evaluation` : `${baseUrl}/evaluations`; + case 'announcement': + case 'system': + return `${baseUrl}/announcements`; + default: + return baseUrl; + } + } + + // ID가 있는 경우 + switch (type) { + case 'project': + return `${baseUrl}/projects/${id}`; + case 'task': + return `${baseUrl}/tasks/${id}`; + case 'order': + return `${baseUrl}/orders/${id}`; + case 'evaluation_submission': + return domain === 'partners' + ? `${baseUrl}/evaluation/${id}` + : `${baseUrl}/evaluations/submissions/${id}`; + case 'document': + return `${baseUrl}/documents/${id}`; + case 'approval': + return `${baseUrl}/approvals/${id}`; + default: + return baseUrl; + } + }; + + return ( + <NotificationContext.Provider + value={{ + notifications, + unreadCount, + markAsRead, + markAllAsRead, + refreshNotifications + }} + > + {children} + </NotificationContext.Provider> + ); +} + +export function useNotifications() { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotifications must be used within NotificationProvider'); + } + return context; +}
\ No newline at end of file diff --git a/lib/notification/service.ts b/lib/notification/service.ts new file mode 100644 index 00000000..f0018113 --- /dev/null +++ b/lib/notification/service.ts @@ -0,0 +1,342 @@ +// lib/notification/service.ts +import db from '@/db/db'; +import { notifications, type NewNotification, type Notification } from '@/db/schema'; +import { eq, and, desc, count, gt } from 'drizzle-orm'; + +// 알림 생성 +export async function createNotification(data: Omit<NewNotification, 'id' | 'createdAt'>) { + const [notification] = await db + .insert(notifications) + .values({ + ...data, + createdAt: new Date(), + }) + .returning(); + + return notification; +} + +// 여러 알림 한번에 생성 (벌크 생성) +export async function createNotifications(data: Omit<NewNotification, 'id' | 'createdAt'>[]) { + if (data.length === 0) return []; + + const notificationsData = data.map(item => ({ + ...item, + createdAt: new Date(), + })); + + const createdNotifications = await db + .insert(notifications) + .values(notificationsData) + .returning(); + + return createdNotifications; +} + +// 사용자의 알림 목록 조회 (페이지네이션 포함) +export async function getUserNotifications( + userId: string, + options: { + limit?: number; + offset?: number; + unreadOnly?: boolean; + } = {} +) { + const { limit = 20, offset = 0, unreadOnly = false } = options; + + const whereConditions = [eq(notifications.userId, userId)]; + + if (unreadOnly) { + whereConditions.push(eq(notifications.isRead, false)); + } + + const userNotifications = await db + .select() + .from(notifications) + .where(and(...whereConditions)) + .orderBy(desc(notifications.createdAt)) + .limit(limit) + .offset(offset); + + return userNotifications; +} + +// 읽지 않은 알림 개수 조회 +export async function getUnreadNotificationCount(userId: string) { + const [result] = await db + .select({ count: count() }) + .from(notifications) + .where( + and( + eq(notifications.userId, userId), + eq(notifications.isRead, false) + ) + ); + + return result.count; +} + +// 알림 읽음 처리 +export async function markNotificationAsRead(notificationId: string, userId: string) { + const [updatedNotification] = await db + .update(notifications) + .set({ + isRead: true, + readAt: new Date() + }) + .where( + and( + eq(notifications.id, notificationId), + eq(notifications.userId, userId) + ) + ) + .returning(); + + return updatedNotification; +} + +// 모든 알림 읽음 처리 +export async function markAllNotificationsAsRead(userId: string) { + const updatedNotifications = await db + .update(notifications) + .set({ + isRead: true, + readAt: new Date() + }) + .where( + and( + eq(notifications.userId, userId), + eq(notifications.isRead, false) + ) + ) + .returning(); + + return updatedNotifications; +} + +// 특정 알림 삭제 +export async function deleteNotification(notificationId: string, userId: string) { + const [deletedNotification] = await db + .delete(notifications) + .where( + and( + eq(notifications.id, notificationId), + eq(notifications.userId, userId) + ) + ) + .returning(); + + return deletedNotification; +} + +// 최근 알림 조회 (실시간 업데이트용) +export async function getLatestNotifications(userId: string, since?: Date) { + const whereConditions = [eq(notifications.userId, userId)]; + + if (since) { + whereConditions.push(gt(notifications.createdAt, since)); + } + + const latestNotifications = await db + .select() + .from(notifications) + .where(and(...whereConditions)) + .orderBy(desc(notifications.createdAt)) + .limit(10); + + return latestNotifications; +} + +// ========================= +// 알림 템플릿 및 헬퍼 함수들 +// ========================= + +export const NotificationTemplates = { + // 프로젝트 관련 + projectAssigned: (projectName: string, projectId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '새 프로젝트가 할당되었습니다', + message: `"${projectName}" 프로젝트가 회원님에게 할당되었습니다.`, + type: 'assignment', + relatedRecordId: projectId, + relatedRecordType: 'project', + isRead: false, + }), + + // 작업 관련 + taskAssigned: (taskName: string, taskId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '새 작업이 할당되었습니다', + message: `"${taskName}" 작업이 회원님에게 할당되었습니다.`, + type: 'assignment', + relatedRecordId: taskId, + relatedRecordType: 'task', + isRead: false, + }), + + // 주문 관련 + orderStatusChanged: (orderNumber: string, status: string, orderId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '주문 상태가 변경되었습니다', + message: `주문 ${orderNumber}의 상태가 "${status}"로 변경되었습니다.`, + type: 'status_change', + relatedRecordId: orderId, + relatedRecordType: 'order', + isRead: false, + }), + + // 평가 관련 + evaluationDocumentRequested: (evaluationYear: number, evaluationRound: string, dueDate?: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '협력업체 평가 자료 제출 요청', + message: `${evaluationYear}년 ${evaluationRound} 협력업체 평가 자료 제출이 요청되었습니다.${dueDate ? ` 마감일: ${dueDate}` : ''}`, + type: 'evaluation_request', + relatedRecordId: null, + relatedRecordType: 'evaluation', + isRead: false, + }), + + evaluationRequestCompleted: (vendorCount: number, evaluationYear: number, evaluationRound: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '평가 자료 요청이 완료되었습니다', + message: `${vendorCount}개 협력업체에게 ${evaluationYear}년 ${evaluationRound} 평가 자료 요청이 완료되었습니다.`, + type: 'evaluation_admin', + relatedRecordId: null, + relatedRecordType: 'evaluation', + isRead: false, + }), + + evaluationSubmitted: (vendorName: string, evaluationYear: number, evaluationRound: string, submissionId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '협력업체 평가가 제출되었습니다', + message: `${vendorName}에서 ${evaluationYear}년 ${evaluationRound} 평가를 제출했습니다.`, + type: 'evaluation_submission', + relatedRecordId: submissionId, + relatedRecordType: 'evaluation_submission', + isRead: false, + }), + + // 승인 관련 + approvalRequired: (documentName: string, documentId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '승인이 필요합니다', + message: `"${documentName}" 문서의 승인이 필요합니다.`, + type: 'approval', + relatedRecordId: documentId, + relatedRecordType: 'document', + isRead: false, + }), + + // 마감일 관련 + deadlineReminder: (taskName: string, daysLeft: number, taskId: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title: '마감일 알림', + message: `"${taskName}" 작업의 마감일이 ${daysLeft}일 남았습니다.`, + type: 'deadline', + relatedRecordId: taskId, + relatedRecordType: 'task', + isRead: false, + }), + + // 시스템 공지 + systemAnnouncement: (title: string, message: string): Omit<NewNotification, 'userId' | 'id' | 'createdAt'> => ({ + title, + message, + type: 'announcement', + relatedRecordId: null, + relatedRecordType: 'system', + isRead: false, + }), +}; + +// ========================= +// 비즈니스 로직별 편의 함수들 +// ========================= + +// 프로젝트 할당 알림 +export async function notifyProjectAssignment(userId: string, projectName: string, projectId: string) { + return await createNotification({ + userId, + ...NotificationTemplates.projectAssigned(projectName, projectId), + }); +} + +// 작업 할당 알림 +export async function notifyTaskAssignment(userId: string, taskName: string, taskId: string) { + return await createNotification({ + userId, + ...NotificationTemplates.taskAssigned(taskName, taskId), + }); +} + +// 주문 상태 변경 알림 +export async function notifyOrderStatusChange(userId: string, orderNumber: string, status: string, orderId: string) { + return await createNotification({ + userId, + ...NotificationTemplates.orderStatusChanged(orderNumber, status, orderId), + }); +} + +// 평가 자료 요청 알림 (벤더에게) +export async function notifyEvaluationDocumentRequest( + userIds: string[], + evaluationYear: number, + evaluationRound: string, + dueDate?: string +) { + const template = NotificationTemplates.evaluationDocumentRequested(evaluationYear, evaluationRound, dueDate); + + const notificationsData = userIds.map(userId => ({ + userId, + ...template, + })); + + return await createNotifications(notificationsData); +} + +// 평가 요청 완료 알림 (관리자에게) +export async function notifyEvaluationRequestCompleted( + adminUserIds: string[], + vendorCount: number, + evaluationYear: number, + evaluationRound: string +) { + const template = NotificationTemplates.evaluationRequestCompleted(vendorCount, evaluationYear, evaluationRound); + + const notificationsData = adminUserIds.map(userId => ({ + userId, + ...template, + })); + + return await createNotifications(notificationsData); +} + +// 평가 제출 알림 (평가 담당자에게) +export async function notifyEvaluationSubmission( + evaluatorUserIds: string[], + vendorName: string, + evaluationYear: number, + evaluationRound: string, + submissionId: string +) { + const template = NotificationTemplates.evaluationSubmitted(vendorName, evaluationYear, evaluationRound, submissionId); + + const notificationsData = evaluatorUserIds.map(userId => ({ + userId, + ...template, + })); + + return await createNotifications(notificationsData); +} + +// 승인 요청 알림 +export async function notifyApprovalRequired(userId: string, documentName: string, documentId: string) { + return await createNotification({ + userId, + ...NotificationTemplates.approvalRequired(documentName, documentId), + }); +} + +// 시스템 공지 (전체 사용자) +export async function broadcastSystemAnnouncement(userIds: string[], title: string, message: string) { + const template = NotificationTemplates.systemAnnouncement(title, message); + + const notificationsData = userIds.map(userId => ({ + userId, + ...template, + })); + + return await createNotifications(notificationsData); +}
\ No newline at end of file |
