'use server' import { revalidatePath, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema"; import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm"; import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations"; import { filterColumns } from "@/lib/filter-columns"; import { saveFile } from "../file-stroage"; interface CreateLegalWorkResult { success: boolean; data?: { id: number; message: string; }; error?: string; } export async function createLegalWork( data: CreateLegalWorkData ): Promise { unstable_noStore(); try { // 1. 입력 데이터 검증 const validatedData = createLegalWorkSchema.parse(data); // 2. 벤더 정보 조회 const vendor = await db .select({ id: vendors.id, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, }) .from(vendors) .where(eq(vendors.id, validatedData.vendorId)) .limit(1); if (!vendor.length) { return { success: false, error: "선택한 벤더를 찾을 수 없습니다.", }; } const selectedVendor = vendor[0]; // 3. 트랜잭션으로 데이터 삽입 const result = await db.transaction(async (tx) => { // 3-1. legal_works 테이블에 메인 데이터 삽입 const [legalWorkResult] = await tx .insert(legalWorks) .values({ category: validatedData.category, status: "신규등록", // 초기 상태 vendorId: validatedData.vendorId, vendorCode: selectedVendor.vendorCode, vendorName: selectedVendor.vendorName, isUrgent: validatedData.isUrgent, requestDate: validatedData.requestDate, consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜 hasAttachment: false, // 초기값 reviewer: validatedData.reviewer, // 추후 할당 legalResponder: null, // 추후 할당 }) .returning({ id: legalWorks.id }); const legalWorkId = legalWorkResult.id; return { legalWorkId }; }); // 4. 캐시 재검증 revalidatePath("/legal-works"); return { success: true, data: { id: result.legalWorkId, message: "법무업무가 성공적으로 등록되었습니다.", }, }; } catch (error) { console.error("createLegalWork 오류:", error); // 데이터베이스 오류 처리 if (error instanceof Error) { // 외래키 제약 조건 오류 if (error.message.includes('foreign key constraint')) { return { success: false, error: "선택한 벤더가 유효하지 않습니다.", }; } // 중복 키 오류 등 기타 DB 오류 return { success: false, error: "데이터베이스 오류가 발생했습니다.", }; } return { success: false, error: "알 수 없는 오류가 발생했습니다.", }; } } // 법무업무 상태 업데이트 함수 (보너스) export async function updateLegalWorkStatus( legalWorkId: number, status: string, reviewer?: string, legalResponder?: string ): Promise { unstable_noStore(); try { const updateData: Partial = { status, updatedAt: new Date(), }; if (reviewer) updateData.reviewer = reviewer; if (legalResponder) updateData.legalResponder = legalResponder; await db .update(legalWorks) .set(updateData) .where(eq(legalWorks.id, legalWorkId)); revalidatePath("/legal-works"); return { success: true, data: { id: legalWorkId, message: "상태가 성공적으로 업데이트되었습니다.", }, }; } catch (error) { console.error("updateLegalWorkStatus 오류:", error); return { success: false, error: "상태 업데이트 중 오류가 발생했습니다.", }; } } // 법무업무 삭제 함수 (보너스) export async function deleteLegalWork(legalWorkId: number): Promise { unstable_noStore(); try { await db.transaction(async (tx) => { // 관련 요청 데이터 먼저 삭제 await tx .delete(legalWorkRequests) .where(eq(legalWorkRequests.legalWorkId, legalWorkId)); // 메인 법무업무 데이터 삭제 await tx .delete(legalWorks) .where(eq(legalWorks.id, legalWorkId)); }); revalidatePath("/legal-works"); return { success: true, data: { id: legalWorkId, message: "법무업무가 성공적으로 삭제되었습니다.", }, }; } catch (error) { console.error("deleteLegalWork 오류:", error); return { success: false, error: "삭제 중 오류가 발생했습니다.", }; } } export async function getLegalWorks(input: GetLegalWorksSchema) { unstable_noStore(); // ✅ 1. 캐싱 방지 추가 try { const offset = (input.page - 1) * input.perPage; // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일) let advancedWhere: SQL | undefined = undefined; if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); try { advancedWhere = filterColumns({ table: legalWorksDetailView, filters: input.filters, joinOperator: input.joinOperator || 'and', }); console.log("필터 조건 생성 완료"); } catch (error) { console.error("필터 조건 생성 오류:", error); // ✅ 필터 오류 시에도 전체 데이터 반환 advancedWhere = undefined; } } // ✅ 3. 안전한 글로벌 검색 처리 let globalWhere: SQL | undefined = undefined; if (input.search) { const searchTerm = `%${input.search}%`; const searchConditions: SQL[] = [ ilike(legalWorksDetailView.vendorCode, searchTerm), ilike(legalWorksDetailView.vendorName, searchTerm), ilike(legalWorksDetailView.title, searchTerm), ilike(legalWorksDetailView.requestContent, searchTerm), ilike(legalWorksDetailView.reviewer, searchTerm), ilike(legalWorksDetailView.legalResponder, searchTerm) ].filter(Boolean); if (searchConditions.length > 0) { globalWhere = or(...searchConditions); } } // ✅ 4. 안전한 WHERE 조건 결합 const whereConditions: SQL[] = []; if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; // ✅ 5. 전체 데이터 수 조회 const totalResult = await db .select({ count: count() }) .from(legalWorksDetailView) .where(finalWhere); const total = totalResult[0]?.count || 0; if (total === 0) { return { data: [], pageCount: 0, total: 0 }; } console.log("총 데이터 수:", total); // ✅ 6. 정렬 및 페이징 처리 const orderByColumns = input.sort.map((sort) => { const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect; return sort.desc ? desc(legalWorksDetailView[column]) : asc(legalWorksDetailView[column]); }); if (orderByColumns.length === 0) { orderByColumns.push(desc(legalWorksDetailView.createdAt)); } const legalWorksData = await db .select() .from(legalWorksDetailView) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) .offset(offset); const pageCount = Math.ceil(total / input.perPage); console.log("반환 데이터 수:", legalWorksData.length); return { data: legalWorksData, pageCount, total }; } catch (err) { console.error("getLegalWorks 오류:", err); return { data: [], pageCount: 0, total: 0 }; } } // 특정 법무업무 상세 조회 export async function getLegalWorkById(id: number) { unstable_noStore(); try { const result = await db .select() .from(legalWorksDetailView) .where(eq(legalWorksDetailView.id , id)) .limit(1); return result[0] || null; } catch (error) { console.error("getLegalWorkById 오류:", error); return null; } } // 법무업무 통계 (뷰 테이블 사용) export async function getLegalWorksStats() { unstable_noStore(); try { // 전체 통계 const totalStats = await db .select({ total: count(), category: legalWorksDetailView.category, status: legalWorksDetailView.status, isUrgent: legalWorksDetailView.isUrgent, }) .from(legalWorksDetailView); // 통계 데이터 가공 const stats = { total: totalStats.length, byCategory: {} as Record, byStatus: {} as Record, urgent: 0, }; totalStats.forEach(stat => { // 카테고리별 집계 if (stat.category) { stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1; } // 상태별 집계 if (stat.status) { stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1; } // 긴급 건수 if (stat.isUrgent) { stats.urgent++; } }); return stats; } catch (error) { console.error("getLegalWorksStatsSimple 오류:", error); return { total: 0, byCategory: {}, byStatus: {}, urgent: 0, }; } } // 검토요청 폼 데이터 타입 interface RequestReviewData { // 기본 설정 dueDate: string assignee?: string notificationMethod: "email" | "internal" | "both" // 법무업무 상세 정보 reviewDepartment: "준법문의" | "법무검토" inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문" // 공통 필드 title: string requestContent: string // 준법문의 전용 필드 isPublic?: boolean // 법무검토 전용 필드들 contractProjectName?: string contractType?: string contractCounterparty?: string counterpartyType?: "법인" | "개인" contractPeriod?: string contractAmount?: string factualRelation?: string projectNumber?: string shipownerOrderer?: string projectType?: string governingLaw?: string } // 첨부파일 업로드 함수 async function uploadAttachment(file: File, legalWorkId: number, userId?: string) { try { console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`) const result = await saveFile({ file, directory: "legal-works", originalName: file.name, userId: userId || "system" }) if (!result.success) { throw new Error(result.error || "파일 업로드 실패") } console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`) return { fileName: result.fileName!, originalFileName: result.originalName!, filePath: result.publicPath!, fileSize: result.fileSize!, mimeType: file.type, securityChecks: result.securityChecks } } catch (error) { console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error) throw error } } export async function requestReview( legalWorkId: number, formData: RequestReviewData, attachments: File[] = [], userId?: string ) { try { console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`) // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. legal_works 테이블 업데이트 const [updatedWork] = await tx .update(legalWorks) .set({ status: "검토요청", expectedAnswerDate: formData.dueDate, hasAttachment: attachments.length > 0, updatedAt: new Date(), }) .where(eq(legalWorks.id, legalWorkId)) .returning() if (!updatedWork) { throw new Error("법무업무를 찾을 수 없습니다.") } console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`) // 2. legal_work_requests 테이블에 데이터 삽입 const [createdRequest] = await tx .insert(legalWorkRequests) .values({ legalWorkId: legalWorkId, reviewDepartment: formData.reviewDepartment, inquiryType: formData.inquiryType || null, title: formData.title, requestContent: formData.requestContent, // 준법문의 관련 필드 isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null, // 법무검토 관련 필드들 contractProjectName: formData.contractProjectName || null, contractType: formData.contractType || null, contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null, // 국내계약 전용 필드들 contractCounterparty: formData.contractCounterparty || null, counterpartyType: formData.counterpartyType || null, contractPeriod: formData.contractPeriod || null, // 자문 관련 필드 factualRelation: formData.factualRelation || null, // 해외 관련 필드들 projectNumber: formData.projectNumber || null, shipownerOrderer: formData.shipownerOrderer || null, governingLaw: formData.governingLaw || null, projectType: formData.projectType || null, }) .returning() console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`) // 3. 첨부파일 처리 const uploadedFiles = [] const failedFiles = [] if (attachments.length > 0) { console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`) for (const file of attachments) { try { const uploadResult = await uploadAttachment(file, legalWorkId, userId) // DB에 첨부파일 정보 저장 const [attachmentRecord] = await tx .insert(legalWorkAttachments) .values({ legalWorkId: legalWorkId, fileName: uploadResult.fileName, originalFileName: uploadResult.originalFileName, filePath: uploadResult.filePath, fileSize: uploadResult.fileSize, mimeType: uploadResult.mimeType, attachmentType: 'request', isAutoGenerated: false, }) .returning() uploadedFiles.push({ id: attachmentRecord.id, name: uploadResult.originalFileName, size: uploadResult.fileSize, securityChecks: uploadResult.securityChecks }) } catch (fileError) { console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError) failedFiles.push({ name: file.name, error: fileError instanceof Error ? fileError.message : "업로드 실패" }) } } console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`) } return { updatedWork, createdRequest, uploadedFiles, failedFiles, totalFiles: attachments.length, } }) // 페이지 재검증 revalidatePath("/legal-works") // 성공 메시지 구성 let message = `검토요청이 성공적으로 발송되었습니다.` if (result.totalFiles > 0) { message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개` if (result.failedFiles.length > 0) { message += `, 실패 ${result.failedFiles.length}개` } message += `)` } console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`) return { success: true, data: { message, legalWorkId: legalWorkId, requestId: result.createdRequest.id, uploadedFiles: result.uploadedFiles, failedFiles: result.failedFiles, } } } catch (error) { console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error) return { success: false, error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다." } } } // FormData를 사용하는 버전 (파일 업로드용) export async function requestReviewWithFiles(formData: FormData) { try { // 기본 데이터 추출 const legalWorkId = parseInt(formData.get("legalWorkId") as string) const requestData: RequestReviewData = { dueDate: formData.get("dueDate") as string, assignee: formData.get("assignee") as string || undefined, notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both", reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토", inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined, title: formData.get("title") as string, requestContent: formData.get("requestContent") as string, isPublic: formData.get("isPublic") === "true", // 법무검토 관련 필드들 contractProjectName: formData.get("contractProjectName") as string || undefined, contractType: formData.get("contractType") as string || undefined, contractCounterparty: formData.get("contractCounterparty") as string || undefined, counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined, contractPeriod: formData.get("contractPeriod") as string || undefined, contractAmount: formData.get("contractAmount") as string || undefined, factualRelation: formData.get("factualRelation") as string || undefined, projectNumber: formData.get("projectNumber") as string || undefined, shipownerOrderer: formData.get("shipownerOrderer") as string || undefined, projectType: formData.get("projectType") as string || undefined, governingLaw: formData.get("governingLaw") as string || undefined, } // 첨부파일 추출 const attachments: File[] = [] for (const [key, value] of formData.entries()) { if (key.startsWith("attachment_") && value instanceof File && value.size > 0) { attachments.push(value) } } return await requestReview(legalWorkId, requestData, attachments) } catch (error) { console.error("FormData 처리 중 오류:", error) return { success: false, error: "요청 데이터 처리 중 오류가 발생했습니다." } } } // 검토요청 가능 여부 확인 export async function canRequestReview(legalWorkId: number) { try { const [work] = await db .select({ status: legalWorks.status }) .from(legalWorks) .where(eq(legalWorks.id, legalWorkId)) .limit(1) if (!work) { return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." } } if (work.status !== "신규등록") { return { canRequest: false, reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.` } } return { canRequest: true } } catch (error) { console.error("검토요청 가능 여부 확인 중 오류:", error) return { canRequest: false, reason: "상태 확인 중 오류가 발생했습니다." } } } // 삭제 요청 타입 interface RemoveLegalWorksInput { ids: number[] } // 응답 타입 interface RemoveLegalWorksResponse { error?: string success?: boolean } /** * 법무업무 삭제 서버 액션 */ export async function removeLegalWorks({ ids, }: RemoveLegalWorksInput): Promise { try { // 유효성 검사 if (!ids || ids.length === 0) { return { error: "삭제할 법무업무를 선택해주세요.", } } // 삭제 가능한 상태인지 확인 (선택적) const existingWorks = await db .select({ id: legalWorks.id, status: legalWorks.status }) .from(legalWorks) .where(inArray(legalWorks.id, ids)) // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가) const nonDeletableWorks = existingWorks.filter( work => work.status === "검토중" || work.status === "담당자배정" ) if (nonDeletableWorks.length > 0) { return { error: "진행중인 법무업무는 삭제할 수 없습니다.", } } // 실제 삭제 실행 const result = await db .delete(legalWorks) .where(inArray(legalWorks.id, ids)) // 결과 확인 if (result.changes === 0) { return { error: "삭제할 법무업무를 찾을 수 없습니다.", } } // 캐시 재검증 revalidatePath("/legal-works") // 실제 경로에 맞게 수정 return { success: true, } } catch (error) { console.error("법무업무 삭제 중 오류 발생:", error) return { error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.", } } } /** * 단일 법무업무 삭제 (선택적) */ export async function removeLegalWork(id: number): Promise { return removeLegalWorks({ ids: [id] }) }