"use server"; import { revalidateTag } from "next/cache"; import db from "@/db/db"; import { eq, and, desc, inArray, sql, isNotNull, ne } from "drizzle-orm"; import { agreementComments, basicContract, basicContractTemplates, vendors, users } from "@/db/schema"; import { saveFile, deleteFile } from "@/lib/file-stroage"; import { sendEmail } from "@/lib/mail/sendEmail"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; export type AgreementCommentAuthorType = 'SHI' | 'Vendor'; export interface AgreementCommentAttachment { id: string; fileName: string; filePath: string; fileSize: number; uploadedAt: Date; } export interface AgreementCommentData { id: number; basicContractId: number; authorType: AgreementCommentAuthorType; authorUserId: number | null; authorVendorId: number | null; authorName: string | null; comment: string; attachments: AgreementCommentAttachment[]; isSubmitted: boolean; submittedAt: Date | null; createdAt: Date; updatedAt: Date; } export interface AgreementCommentSummary { hasComments: boolean; commentCount: number; } function getContractDocumentLabel(templateName?: string | null) { if (!templateName) return "기본계약서"; const normalized = templateName.toLowerCase(); if (normalized.includes('준법')) { return "준법서약"; } if (normalized.includes('gtc')) { return "GTC 기본계약서"; } return templateName; } /** * 특정 기본계약서의 모든 코멘트 조회 */ export async function getAgreementComments( basicContractId: number ): Promise { try { const comments = await db .select() .from(agreementComments) .where( and( eq(agreementComments.basicContractId, basicContractId), eq(agreementComments.isDeleted, false), isNotNull(agreementComments.comment), ne(agreementComments.comment, '') ) ) .orderBy(desc(agreementComments.createdAt)); const mappedComments: AgreementCommentData[] = comments.map((comment) => { // attachments 안전하게 파싱 let attachments: AgreementCommentAttachment[] = []; if (comment.attachments) { try { const attachmentData = comment.attachments; // 문자열인 경우 파싱 시도 if (typeof attachmentData === 'string') { const trimmed = String(attachmentData).trim(); if (trimmed && trimmed !== '') { attachments = JSON.parse(trimmed); } } // 이미 배열인 경우 그대로 사용 else if (Array.isArray(attachmentData)) { attachments = attachmentData as AgreementCommentAttachment[]; } } catch (parseError) { console.warn(`⚠️ [getAgreementComments] 코멘트 ${comment.id}의 attachments 파싱 실패:`, parseError); console.warn(` attachments 값:`, comment.attachments); attachments = []; // 파싱 실패 시 빈 배열로 설정 } } return { ...comment, authorType: comment.authorType as AgreementCommentAuthorType, attachments, isSubmitted: comment.isSubmitted || false, submittedAt: comment.submittedAt || null, } as AgreementCommentData; }); return mappedComments; } catch (error) { console.error("코멘트 조회 실패:", error); throw new Error("코멘트 조회 중 오류가 발생했습니다."); } } /** * 코멘트 추가 (파일 첨부 포함) */ export async function addAgreementComment(data: { basicContractId: number; comment: string; authorName?: string; files?: File[]; shouldSendEmail?: boolean; // 이메일 발송 여부 (제출할 때만 true) }): Promise<{ success: boolean; data?: AgreementCommentData; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 사용자 정보로부터 authorType 결정 // companyId가 있으면 Vendor, 없으면 SHI const user = session.user as any; const isVendor = !!user.companyId; const authorType: AgreementCommentAuthorType = isVendor ? 'Vendor' : 'SHI'; // 기본계약서 정보 조회 (이메일 발송을 위해) const [contract] = await db .select() .from(basicContract) .where(eq(basicContract.id, data.basicContractId)) .limit(1); if (!contract) { return { success: false, error: "계약서를 찾을 수 없습니다." }; } // 벤더 정보 조회 (이메일 발송용) let vendor: any = null; if (contract.vendorId) { [vendor] = await db .select() .from(vendors) .where(eq(vendors.id, contract.vendorId)) .limit(1); } // 요청자 정보 조회 (이메일 발송용) let requester: any = null; if (contract.requestedBy) { [requester] = await db .select() .from(users) .where(eq(users.id, contract.requestedBy)) .limit(1); } // 템플릿 이름 조회 let templateName: string | null = null; if (contract.templateId) { const [template] = await db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.id, contract.templateId)) .limit(1); templateName = template?.templateName || null; } // 파일 업로드 처리 (있을 경우) const uploadedAttachments: AgreementCommentAttachment[] = []; if (data.files && data.files.length > 0) { for (const file of data.files) { try { const saveResult = await saveFile({ file, directory: "agreement-comments", originalName: file.name, userId: user.id?.toString(), }); if (saveResult.success && saveResult.publicPath) { uploadedAttachments.push({ id: crypto.randomUUID(), fileName: file.name, filePath: saveResult.publicPath, fileSize: file.size, uploadedAt: new Date(), }); } } catch (fileError) { console.error(`파일 업로드 실패 (${file.name}):`, fileError); // 개별 파일 실패는 무시하고 계속 진행 } } } // 코멘트 저장 const [newComment] = await db .insert(agreementComments) .values({ basicContractId: data.basicContractId, authorType, authorUserId: isVendor ? null : parseInt(user.id), authorVendorId: isVendor ? user.companyId : null, authorName: data.authorName || user.name, comment: data.comment, attachments: uploadedAttachments.length > 0 ? JSON.stringify(uploadedAttachments) : JSON.stringify([]), isSubmitted: data.shouldSendEmail || false, // 제출 여부 설정 submittedAt: data.shouldSendEmail ? new Date() : null, // 제출 시 제출일시 설정 } as any) .returning(); // 이메일 알림 발송 (shouldSendEmail이 true일 때만) if (data.shouldSendEmail) { try { await sendCommentNotificationEmail({ comment: newComment, contract, vendor, requester, templateName, authorType, authorName: data.authorName || user.name, attachmentCount: uploadedAttachments.length, }); } catch (emailError) { console.error("이메일 발송 실패:", emailError); // 이메일 실패는 코멘트 저장 성공에 영향을 주지 않음 } } // 계약서 상태 업데이트 (협의중으로 변경) await updateContractNegotiationStatus(data.basicContractId); // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 revalidateTag(`agreement-comments-${data.basicContractId}`); revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 return { success: true, data: { ...newComment, authorType: newComment.authorType as AgreementCommentAuthorType, attachments: uploadedAttachments, isSubmitted: newComment.isSubmitted || false, submittedAt: newComment.submittedAt || null, } as AgreementCommentData, }; } catch (error) { console.error("코멘트 추가 실패:", error); return { success: false, error: "코멘트 추가 중 오류가 발생했습니다.", }; } } /** * 코멘트 삭제 */ export async function deleteAgreementComment( commentId: number ): Promise<{ success: boolean; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 코멘트 조회 const [comment] = await db .select() .from(agreementComments) .where(eq(agreementComments.id, commentId)); if (!comment) { return { success: false, error: "코멘트를 찾을 수 없습니다." }; } // 권한 확인 (작성자만 삭제 가능) const user = session.user as any; const isVendor = !!user.companyId; const canDelete = (isVendor && comment.authorVendorId === user.companyId) || (!isVendor && comment.authorUserId === parseInt(user.id)); if (!canDelete) { return { success: false, error: "삭제 권한이 없습니다." }; } // Soft delete await db .update(agreementComments) .set({ isDeleted: true, deletedAt: new Date(), updatedAt: new Date(), } as any) .where(eq(agreementComments.id, commentId)); // 첨부파일이 있으면 파일 시스템에서도 삭제 if (comment.attachments) { try { const attachmentsStr = typeof comment.attachments === 'string' ? comment.attachments : JSON.stringify(comment.attachments); const attachments: AgreementCommentAttachment[] = JSON.parse(attachmentsStr); for (const attachment of attachments) { try { await deleteFile(attachment.filePath); } catch (fileError) { console.error("파일 삭제 실패:", fileError); } } } catch (parseError) { console.error("첨부파일 파싱 실패:", parseError); } } // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 revalidateTag(`agreement-comments-${comment.basicContractId}`); revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 return { success: true }; } catch (error) { console.error("코멘트 삭제 실패:", error); return { success: false, error: "코멘트 삭제 중 오류가 발생했습니다.", }; } } /** * 코멘트 제출 (이메일 발송) */ export async function submitAgreementComment( commentId: number ): Promise<{ success: boolean; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 코멘트 조회 const [comment] = await db .select() .from(agreementComments) .where(eq(agreementComments.id, commentId)); if (!comment) { return { success: false, error: "코멘트를 찾을 수 없습니다." }; } // 이미 제출된 코멘트인지 확인 if (comment.isSubmitted) { return { success: false, error: "이미 제출된 코멘트입니다." }; } // 권한 확인 (작성자만 제출 가능) const user = session.user as any; const isVendor = !!user.companyId; const canSubmit = (isVendor && comment.authorVendorId === user.companyId) || (!isVendor && comment.authorUserId === parseInt(user.id)); if (!canSubmit) { return { success: false, error: "제출 권한이 없습니다." }; } // 기본계약서 정보 조회 (이메일 발송을 위해) const [contract] = await db .select() .from(basicContract) .where(eq(basicContract.id, comment.basicContractId)) .limit(1); if (!contract) { return { success: false, error: "계약서를 찾을 수 없습니다." }; } // 벤더 정보 조회 (이메일 발송용) let vendor: any = null; if (contract.vendorId) { [vendor] = await db .select() .from(vendors) .where(eq(vendors.id, contract.vendorId)) .limit(1); } // 요청자 정보 조회 (이메일 발송용) let requester: any = null; if (contract.requestedBy) { [requester] = await db .select() .from(users) .where(eq(users.id, contract.requestedBy)) .limit(1); } // 템플릿 이름 조회 let templateName: string | null = null; if (contract.templateId) { const [template] = await db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.id, contract.templateId)) .limit(1); templateName = template?.templateName || null; } // 첨부파일 정보 파싱 let attachments: AgreementCommentAttachment[] = []; if (comment.attachments) { try { const attachmentsStr = typeof comment.attachments === 'string' ? comment.attachments : JSON.stringify(comment.attachments); attachments = JSON.parse(attachmentsStr); } catch (parseError) { console.error("첨부파일 파싱 실패:", parseError); } } // 코멘트 제출 상태 업데이트 await db .update(agreementComments) .set({ isSubmitted: true, submittedAt: new Date(), updatedAt: new Date(), } as any) .where(eq(agreementComments.id, commentId)); // 이메일 알림 발송 try { await sendCommentNotificationEmail({ comment: { ...comment, isSubmitted: true, submittedAt: new Date(), }, contract, vendor, requester, templateName, authorType: comment.authorType as AgreementCommentAuthorType, authorName: comment.authorName || user.name, attachmentCount: attachments.length, }); } catch (emailError) { console.error("이메일 발송 실패:", emailError); // 이메일 실패는 제출 성공에 영향을 주지 않음 } // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 revalidateTag(`agreement-comments-${comment.basicContractId}`); revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 return { success: true }; } catch (error) { console.error("코멘트 제출 실패:", error); return { success: false, error: "코멘트 제출 중 오류가 발생했습니다.", }; } } /** * 첨부파일 업로드 */ export async function uploadCommentAttachment( commentId: number, file: File ): Promise<{ success: boolean; data?: AgreementCommentAttachment; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 코멘트 조회 const [comment] = await db .select() .from(agreementComments) .where(eq(agreementComments.id, commentId)); if (!comment) { return { success: false, error: "코멘트를 찾을 수 없습니다." }; } // 파일 저장 const saveResult = await saveFile({ file, directory: "agreement-comments", originalName: file.name, userId: (session.user as any).id?.toString(), }); if (!saveResult.success) { return { success: false, error: saveResult.error }; } // 첨부파일 정보 생성 const newAttachment: AgreementCommentAttachment = { id: crypto.randomUUID(), fileName: file.name, filePath: saveResult.publicPath!, fileSize: file.size, uploadedAt: new Date(), }; // 기존 첨부파일에 추가 let existingAttachments: AgreementCommentAttachment[] = []; if (comment.attachments) { try { const attachmentsStr = typeof comment.attachments === 'string' ? comment.attachments : JSON.stringify(comment.attachments); existingAttachments = JSON.parse(attachmentsStr); } catch (parseError) { console.error("기존 첨부파일 파싱 실패:", parseError); } } existingAttachments.push(newAttachment); // DB 업데이트 await db .update(agreementComments) .set({ attachments: JSON.stringify(existingAttachments), updatedAt: new Date(), } as any) .where(eq(agreementComments.id, commentId)); revalidateTag(`agreement-comments-${comment.basicContractId}`); return { success: true, data: newAttachment }; } catch (error) { console.error("첨부파일 업로드 실패:", error); return { success: false, error: "첨부파일 업로드 중 오류가 발생했습니다.", }; } } /** * 첨부파일 삭제 */ export async function deleteCommentAttachment( commentId: number, attachmentId: string ): Promise<{ success: boolean; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 코멘트 조회 const [comment] = await db .select() .from(agreementComments) .where(eq(agreementComments.id, commentId)); if (!comment) { return { success: false, error: "코멘트를 찾을 수 없습니다." }; } // 첨부파일 목록에서 제거 let attachments: AgreementCommentAttachment[] = []; if (comment.attachments) { try { const attachmentsStr = typeof comment.attachments === 'string' ? comment.attachments : JSON.stringify(comment.attachments); attachments = JSON.parse(attachmentsStr); } catch (parseError) { console.error("첨부파일 파싱 실패:", parseError); return { success: false, error: "첨부파일 정보를 읽을 수 없습니다." }; } } const targetAttachment = attachments.find((a) => a.id === attachmentId); if (!targetAttachment) { return { success: false, error: "첨부파일을 찾을 수 없습니다." }; } const updatedAttachments = attachments.filter((a) => a.id !== attachmentId); // DB 업데이트 await db .update(agreementComments) .set({ attachments: JSON.stringify(updatedAttachments), updatedAt: new Date(), } as any) .where(eq(agreementComments.id, commentId)); // 파일 시스템에서 삭제 try { await deleteFile(targetAttachment.filePath); } catch (fileError) { console.error("파일 삭제 실패:", fileError); } revalidateTag(`agreement-comments-${comment.basicContractId}`); return { success: true }; } catch (error) { console.error("첨부파일 삭제 실패:", error); return { success: false, error: "첨부파일 삭제 중 오류가 발생했습니다.", }; } } /** * 협의 상태 확인 (코멘트가 있는지) */ export async function checkNegotiationStatus( basicContractId: number ): Promise<{ hasComments: boolean; commentCount: number }> { try { const comments = await db .select() .from(agreementComments) .where( and( eq(agreementComments.basicContractId, basicContractId), eq(agreementComments.isDeleted, false) ) ); return { hasComments: comments.length > 0, commentCount: comments.length, }; } catch (error) { console.error("협의 상태 확인 실패:", error); return { hasComments: false, commentCount: 0 }; } } /** * 다수의 계약서에 대한 협의 코멘트 상태 조회 */ export async function checkAgreementCommentsForContracts( contracts: { id: number }[] ): Promise> { if (!contracts || contracts.length === 0) { return {}; } try { const contractIds = contracts.map(contract => contract.id); const commentCounts = await db .select({ contractId: agreementComments.basicContractId, count: sql`count(*)`, }) .from(agreementComments) .where( and( inArray(agreementComments.basicContractId, contractIds), eq(agreementComments.isDeleted, false) ) ) .groupBy(agreementComments.basicContractId); const countsMap = new Map(); commentCounts.forEach(({ contractId, count }) => { countsMap.set(contractId, Number(count || 0)); }); const result: Record = {}; contractIds.forEach((contractId) => { const count = countsMap.get(contractId) ?? 0; result[contractId] = { hasComments: count > 0, commentCount: count, }; }); return result; } catch (error) { console.error("협의 상태 조회 실패:", error); return {}; } } /** * 이메일 알림 발송 */ async function sendCommentNotificationEmail(params: { comment: typeof agreementComments.$inferSelect; contract: typeof basicContract.$inferSelect; vendor: typeof vendors.$inferSelect | null; requester: typeof users.$inferSelect | null; templateName: string | null; authorType: AgreementCommentAuthorType; authorName: string; attachmentCount?: number; }) { const { comment, contract, vendor, requester, templateName, authorType, authorName, attachmentCount = 0 } = params; const documentTypeLabel = getContractDocumentLabel(templateName); // 수신자 결정 let recipientEmail: string | undefined; let recipientName: string | undefined; if (authorType === 'Vendor') { // 벤더가 작성한 경우 -> SHI 담당자에게 발송 if (requester) { recipientEmail = requester.email || undefined; recipientName = requester.name || undefined; } } else { // SHI가 작성한 경우 -> 벤더에게 발송 if (vendor) { recipientEmail = vendor.email || undefined; recipientName = vendor.vendorName || undefined; } } if (!recipientEmail) { console.warn("수신자 이메일을 찾을 수 없습니다."); return; } // 이메일 발송 await sendEmail({ to: recipientEmail, subject: `[eVCP] ${documentTypeLabel} 협의 코멘트 제출 - ${templateName || '기본계약서'}`, template: "agreement-comment-notification", context: { language: "ko", recipientName: recipientName || "담당자", authorName, authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체', comment: comment.comment, templateName: templateName || '기본계약서', documentTypeLabel, vendorName: vendor?.vendorName || '', attachmentCount, contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', currentYear: new Date().getFullYear(), }, }); } /** * 협의 완료 처리 */ export async function completeNegotiation( basicContractId: number ): Promise<{ success: boolean; error?: string }> { try { const session = await getServerSession(authOptions); if (!session?.user) { return { success: false, error: "인증이 필요합니다." }; } // 기본계약서 정보 조회 const [contract] = await db .select() .from(basicContract) .where(eq(basicContract.id, basicContractId)) .limit(1); if (!contract) { return { success: false, error: "계약서를 찾을 수 없습니다." }; } // 협의 완료 상태로 업데이트 await db .update(basicContract) .set({ negotiationCompletedAt: new Date(), updatedAt: new Date(), } as any) .where(eq(basicContract.id, basicContractId)); // 벤더 정보 조회 (이메일 발송용) let vendor: any = null; if (contract.vendorId) { [vendor] = await db .select() .from(vendors) .where(eq(vendors.id, contract.vendorId)) .limit(1); } // 요청자 정보 조회 (이메일 발송용) let requester: any = null; if (contract.requestedBy) { [requester] = await db .select() .from(users) .where(eq(users.id, contract.requestedBy)) .limit(1); } // 템플릿 이름 조회 let templateName: string | null = null; if (contract.templateId) { const [template] = await db .select() .from(basicContractTemplates) .where(eq(basicContractTemplates.id, contract.templateId)) .limit(1); templateName = template?.templateName || null; } const documentTypeLabel = getContractDocumentLabel(templateName); // 이메일 알림 발송 try { if (requester) { await sendEmail({ to: requester.email || '', subject: `[eVCP] ${documentTypeLabel} 협의 완료 - ${templateName || '기본계약서'}`, template: "negotiation-complete-notification", context: { language: "ko", recipientName: requester.name || "담당자", templateName: templateName || '기본계약서', documentTypeLabel, vendorName: vendor?.vendorName || '', contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', currentYear: new Date().getFullYear(), }, }); } } catch (emailError) { console.error("이메일 발송 실패:", emailError); // 이메일 실패는 협의 완료 처리 성공에 영향을 주지 않음 } // 캐시 무효화 revalidateTag(`agreement-comments-${basicContractId}`); revalidateTag(`basic-contracts`); return { success: true }; } catch (error) { console.error("협의 완료 처리 실패:", error); return { success: false, error: "협의 완료 처리 중 오류가 발생했습니다.", }; } } /** * 계약서 협의 상태 업데이트 * 실제로 DB 상태를 변경하지 않고, gtcData 조회 시 agreementComments 존재 여부로 판단 */ async function updateContractNegotiationStatus(basicContractId: number) { // agreementComments 테이블에 코멘트가 있으면 // checkGTCCommentsForContract 함수에서 자동으로 hasComments: true 반환 // 별도 상태 업데이트 불필요 console.log(`✅ 계약서 ${basicContractId} 협의 코멘트 추가됨 - 목록에서 자동 반영`); }