summaryrefslogtreecommitdiff
path: root/lib/basic-contract/agreement-comments/actions.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/agreement-comments/actions.ts')
-rw-r--r--lib/basic-contract/agreement-comments/actions.ts510
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} 협의 코멘트 추가됨 - 목록에서 자동 반영`);
+}
+
+
+