diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-17 08:43:00 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-17 08:43:00 +0000 |
| commit | 1a6774d195b5fb9e3547f3268bf3527a8718c9bf (patch) | |
| tree | 541a4367ec3ffa9abfb8a9256c24f6286628b71c /lib/basic-contract/agreement-comments/actions.ts | |
| parent | d19cca70ad1689807192a8784efc3091bf677816 (diff) | |
(임수민) GTC 기본계약 코멘트 수정
Diffstat (limited to 'lib/basic-contract/agreement-comments/actions.ts')
| -rw-r--r-- | lib/basic-contract/agreement-comments/actions.ts | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts new file mode 100644 index 00000000..13db2fc6 --- /dev/null +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -0,0 +1,510 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import db from "@/db/db"; +import { eq, and, desc } from "drizzle-orm"; +import { agreementComments, basicContract, 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[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * 특정 기본계약서의 모든 코멘트 조회 + */ +export async function getAgreementComments( + basicContractId: number +): Promise<AgreementCommentData[]> { + try { + const comments = await db + .select() + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .orderBy(desc(agreementComments.createdAt)); + + const mappedComments: AgreementCommentData[] = comments.map((comment) => { + // attachments 안전하게 파싱 + let attachments: AgreementCommentAttachment[] = []; + + if (comment.attachments) { + try { + // 문자열인 경우 파싱 시도 + if (typeof comment.attachments === 'string') { + const trimmed = comment.attachments.trim(); + if (trimmed && trimmed !== '') { + attachments = JSON.parse(trimmed); + } + } + // 이미 배열인 경우 그대로 사용 + else if (Array.isArray(comment.attachments)) { + attachments = comment.attachments; + } + } catch (parseError) { + console.warn(`⚠️ [getAgreementComments] 코멘트 ${comment.id}의 attachments 파싱 실패:`, parseError); + console.warn(` attachments 값:`, comment.attachments); + attachments = []; // 파싱 실패 시 빈 배열로 설정 + } + } + + return { + ...comment, + authorType: comment.authorType as AgreementCommentAuthorType, + attachments, + } as AgreementCommentData; + }); + + + return mappedComments; + } catch (error) { + console.error("코멘트 조회 실패:", error); + throw new Error("코멘트 조회 중 오류가 발생했습니다."); + } +} + +/** + * 코멘트 추가 + */ +export async function addAgreementComment(data: { + basicContractId: number; + comment: string; + authorName?: string; +}): 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); + } + + // 템플릿 이름 조회 + const { basicContractTemplates } = await import("@/db/schema"); + 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 [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: JSON.stringify([]), + }) + .returning(); + + // 이메일 알림 발송 + try { + await sendCommentNotificationEmail({ + comment: newComment, + contract, + vendor, + requester, + templateName, + authorType, + authorName: data.authorName || user.name, + }); + } 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: [], + } 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(), + }) + .where(eq(agreementComments.id, commentId)); + + // 첨부파일이 있으면 파일 시스템에서도 삭제 + if (comment.attachments) { + const attachments: AgreementCommentAttachment[] = JSON.parse( + comment.attachments + ); + for (const attachment of attachments) { + try { + await deleteFile(attachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } + } + } + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + 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(), + }; + + // 기존 첨부파일에 추가 + const existingAttachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + existingAttachments.push(newAttachment); + + // DB 업데이트 + await db + .update(agreementComments) + .set({ + attachments: JSON.stringify(existingAttachments), + updatedAt: new Date(), + }) + .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: "코멘트를 찾을 수 없습니다." }; + } + + // 첨부파일 목록에서 제거 + const attachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + 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(), + }) + .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 }; + } +} + +/** + * 이메일 알림 발송 + */ +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; +}) { + const { comment, contract, vendor, requester, templateName, authorType, authorName } = params; + + // 수신자 결정 + 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] GTC 기본계약서 협의 코멘트 - ${templateName || '기본계약서'}`, + template: "agreement-comment-notification", + context: { + language: "ko", + recipientName: recipientName || "담당자", + authorName, + authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체', + comment: comment.comment, + templateName: templateName || '기본계약서', + 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(), + }, + }); +} + +/** + * 계약서 협의 상태 업데이트 + * 실제로 DB 상태를 변경하지 않고, gtcData 조회 시 agreementComments 존재 여부로 판단 + */ +async function updateContractNegotiationStatus(basicContractId: number) { + // agreementComments 테이블에 코멘트가 있으면 + // checkGTCCommentsForContract 함수에서 자동으로 hasComments: true 반환 + // 별도 상태 업데이트 불필요 + console.log(`✅ 계약서 ${basicContractId} 협의 코멘트 추가됨 - 목록에서 자동 반영`); +} + + + |
