summaryrefslogtreecommitdiff
path: root/lib/notification
diff options
context:
space:
mode:
Diffstat (limited to 'lib/notification')
-rw-r--r--lib/notification/NotificationContext.tsx219
-rw-r--r--lib/notification/service.ts342
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