// lib/forms/services.ts "use server"; import { headers } from "next/headers"; import path from "path"; import fs from "fs/promises"; import { v4 as uuidv4 } from "uuid"; import db from "@/db/db"; import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings, vendorDataReportTemps, VendorDataReportTemps, } from "@/db/schema/vendorData"; import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm"; import { unstable_cache } from "next/cache"; import { revalidateTag } from "next/cache"; import { getErrorMessage } from "../handle-error"; import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; export interface FormInfo { id: number; formCode: string; formName: string; // tagType: string } export async function getFormsByContractItemId(contractItemId: number | null) { // 유효성 검사 if (!contractItemId || contractItemId <= 0) { console.warn(`Invalid contractItemId: ${contractItemId}`); return { forms: [] }; } // 고유 캐시 키 const cacheKey = `forms-${contractItemId}`; try { return unstable_cache( async () => { console.log( `[Forms Service] Fetching forms for contractItemId: ${contractItemId}` ); try { // 데이터베이스에서 폼 조회 const formRecords = await db .select({ id: forms.id, formCode: forms.formCode, formName: forms.formName, // tagType: forms.tagType, }) .from(forms) .where(eq(forms.contractItemId, contractItemId)); console.log( `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}` ); // 결과가 배열인지 확인 if (!Array.isArray(formRecords)) { getErrorMessage( `Unexpected result format for contractItemId ${contractItemId} ${formRecords}` ); return { forms: [] }; } return { forms: formRecords }; } catch (error) { getErrorMessage( `Database error for contractItemId ${contractItemId}: ${error}` ); throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 } }, [cacheKey], { // 캐시 시간 단축 revalidate: 60, // 1분으로 줄임 tags: [cacheKey], } )(); } catch (error) { getErrorMessage( `Cache operation failed for contractItemId ${contractItemId}: ${error}` ); // 캐시 문제 시 직접 쿼리 시도 try { console.log( `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}` ); const formRecords = await db .select({ id: forms.id, formCode: forms.formCode, formName: forms.formName, // tagType: forms.tagType, }) .from(forms) .where(eq(forms.contractItemId, contractItemId)); return { forms: formRecords }; } catch (dbError) { getErrorMessage( `Fallback query failed for contractItemId ${contractItemId}:${dbError}` ); return { forms: [] }; } } } /** * 폼 캐시를 갱신하는 서버 액션 */ export async function revalidateForms(contractItemId: number) { if (!contractItemId) return; const cacheKey = `forms-${contractItemId}`; console.log(`[Forms Service] Invalidating cache for ${cacheKey}`); try { revalidateTag(cacheKey); console.log(`[Forms Service] Cache invalidated for ${cacheKey}`); } catch (error) { getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`); } } /** * "가장 최신 1개 row"를 가져오고, * data가 배열이면 그 배열을 반환, * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. */ export async function getFormData(formCode: string, contractItemId: number) { // 고유 캐시 키 (formCode + contractItemId) const cacheKey = `form-data-${formCode}-${contractItemId}`; try { // 1) unstable_cache로 전체 로직을 감싼다 const result = await unstable_cache( async () => { // --- 기존 로직 시작 --- // (1) form_metas 조회 (가정상 1개만 존재) const metaRows = await db .select() .from(formMetas) .where(eq(formMetas.formCode, formCode)) .orderBy(desc(formMetas.updatedAt)) .limit(1); const meta = metaRows[0] ?? null; if (!meta) { return { columns: null, data: [] }; } // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행 const entryRows = await db .select() .from(formEntries) .where( and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId) ) ) .orderBy(desc(formEntries.updatedAt)) .limit(1); const entry = entryRows[0] ?? null; // columns: DB에 저장된 JSON (DataTableColumnJSON[]) const columns = meta.columns as DataTableColumnJSON[]; columns.forEach((col) => { // 이미 displayLabel이 있으면 그대로 두고, // 없으면 uom이 있으면 "label (uom)" 형태, // 둘 다 없으면 label만 쓴다. if (!col.displayLabel) { if (col.uom) { col.displayLabel = `${col.label} (${col.uom})`; } else { col.displayLabel = col.label; } } }); // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열 let data: Array> = []; if (entry) { if (Array.isArray(entry.data)) { data = entry.data; } else { console.warn( "formEntries data was not an array. Using empty array." ); } } return { columns, data }; // --- 기존 로직 끝 --- }, [cacheKey], // 캐시 키 의존성 { revalidate: 60, // 1분 캐시 tags: [cacheKey], // 캐시 태그 } )(); return result; } catch (cacheError) { console.error(`[getFormData] Cache operation failed:`, cacheError); // --- fallback: 캐시 문제 시 직접 쿼리 시도 --- try { console.log( `[getFormData] Fallback DB query for (${formCode}, ${contractItemId})` ); // (1) form_metas const metaRows = await db .select() .from(formMetas) .where(eq(formMetas.formCode, formCode)) .orderBy(desc(formMetas.updatedAt)) .limit(1); const meta = metaRows[0] ?? null; if (!meta) { return { columns: null, data: [] }; } // (2) form_entries const entryRows = await db .select() .from(formEntries) .where( and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId) ) ) .orderBy(desc(formEntries.updatedAt)) .limit(1); const entry = entryRows[0] ?? null; const columns = meta.columns as DataTableColumnJSON[]; columns.forEach((col) => { // 이미 displayLabel이 있으면 그대로 두고, // 없으면 uom이 있으면 "label (uom)" 형태, // 둘 다 없으면 label만 쓴다. if (!col.displayLabel) { if (col.uom) { col.displayLabel = `${col.label} (${col.uom})`; } else { col.displayLabel = col.label; } } }); let data: Array> = []; if (entry) { if (Array.isArray(entry.data)) { data = entry.data; } else { console.warn( "formEntries data was not an array. Using empty array (fallback)." ); } } return { columns, data }; } catch (dbError) { console.error(`[getFormData] Fallback DB query failed:`, dbError); return { columns: null, data: [] }; } } } // export async function syncMissingTags(contractItemId: number, formCode: string) { // // (1) forms 테이블에서 (contractItemId, formCode) 찾기 // const [formRow] = await db // .select() // .from(forms) // .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))) // .limit(1) // if (!formRow) { // throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`) // } // const { tagType, class: className } = formRow // // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기 // const tagRows = await db // .select() // .from(tags) // .where( // and( // eq(tags.contractItemId, contractItemId), // eq(tags.tagType, tagType), // eq(tags.class, className), // ) // ) // if (tagRows.length === 0) { // console.log("No matching tags found.") // return { createdCount: 0 } // } // // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회 // let [entry] = await db // .select() // .from(formEntries) // .where( // and( // eq(formEntries.contractItemId, contractItemId), // eq(formEntries.formCode, formCode) // ) // ) // .limit(1) // // (4) 만약 없다면 새로 insert: data = [] // if (!entry) { // const [inserted] = await db.insert(formEntries).values({ // contractItemId, // formCode, // data: [], // 초기 상태는 빈 배열 // }).returning() // entry = inserted // } // // entry.data는 배열이라고 가정 // // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅 // const existingData = entry.data as Array<{ tagNumber: string }> // let createdCount = 0 // // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push // const updatedArray = [...existingData] // for (const tagRow of tagRows) { // const tagNo = tagRow.tagNo // const found = updatedArray.some(item => item.tagNumber === tagNo) // if (!found) { // updatedArray.push({ tagNumber: tagNo }) // createdCount++ // } // } // // (6) 변경이 있으면 UPDATE // if (createdCount > 0) { // await db // .update(formEntries) // .set({ data: updatedArray }) // .where(eq(formEntries.id, entry.id)) // } // revalidateTag(`form-data-${formCode}-${contractItemId}`); // return { createdCount } // } export async function syncMissingTags( contractItemId: number, formCode: string ) { // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). const [formRow] = await db .select() .from(forms) .where( and( eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode) ) ) .limit(1); if (!formRow) { throw new Error( `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` ); } // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode. const formMappings = await db .select() .from(tagTypeClassFormMappings) .where(eq(tagTypeClassFormMappings.formCode, formCode)); // If no mappings are found, there's nothing to sync. if (formMappings.length === 0) { console.log(`No mappings found for formCode=${formCode}`); return { createdCount: 0, updatedCount: 0, deletedCount: 0 }; } // Build a dynamic OR clause to match (tagType, class) pairs from the mappings. const orConditions = formMappings.map((m) => and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel)) ); // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. const tagRows = await db .select() .from(tags) .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))); // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). let [entry] = await db .select() .from(formEntries) .where( and( eq(formEntries.contractItemId, contractItemId), eq(formEntries.formCode, formCode) ) ) .limit(1); if (!entry) { const [inserted] = await db .insert(formEntries) .values({ contractItemId, formCode, data: [], // Initialize with empty array }) .returning(); entry = inserted; } // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정 const existingData = entry.data as Array<{ tagNumber: string; tagDescription?: string; }>; // Create a Set of valid tagNumbers from tagRows for efficient lookup const validTagNumbers = new Set(tagRows.map((tag) => tag.tagNo)); // Copy existing data to work with let updatedData: Array<{ tagNumber: string; tagDescription?: string; }> = []; let createdCount = 0; let updatedCount = 0; let deletedCount = 0; // First, filter out items that should be deleted (not in validTagNumbers) for (const item of existingData) { if (validTagNumbers.has(item.tagNumber)) { updatedData.push(item); } else { deletedCount++; } } // (5) For each tagRow, if it's missing in updatedData, push it in. // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음. for (const tagRow of tagRows) { const { tagNo, description } = tagRow; // 5-1. 기존 데이터에서 tagNumber 매칭 const existingIndex = updatedData.findIndex( (item) => item.tagNumber === tagNo ); // 5-2. 없다면 새로 추가 if (existingIndex === -1) { updatedData.push({ tagNumber: tagNo, tagDescription: description ?? "", }); createdCount++; } else { // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) const existingItem = updatedData[existingIndex]; if (existingItem.tagDescription !== description) { updatedData[existingIndex] = { ...existingItem, tagDescription: description ?? "", }; updatedCount++; } } } // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { await db .update(formEntries) .set({ data: updatedData }) .where(eq(formEntries.id, entry.id)); } // 캐시 무효화 등 후처리 revalidateTag(`form-data-${formCode}-${contractItemId}`); return { createdCount, updatedCount, deletedCount }; } /** * updateFormDataInDB: * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트 * 업데이트 후, revalidateTag()로 캐시 무효화. */ type UpdateResponse = { success: boolean; message: string; data?: any; }; export async function updateFormDataInDB( formCode: string, contractItemId: number, newData: Record ): Promise { try { // 1) tagNumber로 식별 const tagNumber = newData.tagNumber; if (!tagNumber) { return { success: false, message: "tagNumber는 필수 항목입니다.", }; } // 2) row 찾기 (단 하나) const entries = await db .select() .from(formEntries) .where( and( eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId) ) ) .limit(1); if (!entries || entries.length === 0) { return { success: false, message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`, }; } const entry = entries[0]; // 3) data가 배열인지 확인 if (!entry.data) { return { success: false, message: "폼 데이터가 없습니다.", }; } const dataArray = entry.data as Array>; if (!Array.isArray(dataArray)) { return { success: false, message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다.", }; } // 4) tagNumber = newData.tagNumber 항목 찾기 const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber); if (idx < 0) { return { success: false, message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`, }; } // 5) 병합 const oldItem = dataArray[idx]; const updatedItem = { ...oldItem, ...newData, tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지 }; const updatedArray = [...dataArray]; updatedArray[idx] = updatedItem; // 6) DB UPDATE try { await db .update(formEntries) .set({ data: updatedArray, updatedAt: new Date(), // 업데이트 시간도 갱신 }) .where(eq(formEntries.id, entry.id)); } catch (dbError) { console.error("Database update error:", dbError); if (dbError instanceof DrizzleError) { return { success: false, message: `데이터베이스 업데이트 오류: ${dbError.message}`, }; } return { success: false, message: "데이터베이스 업데이트 중 오류가 발생했습니다.", }; } // 7) Cache 무효화 try { // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 const cacheTag = `form-data-${formCode}-${contractItemId}`; revalidateTag(cacheTag); } catch (cacheError) { console.warn("Cache revalidation warning:", cacheError); // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김 } return { success: true, message: "데이터가 성공적으로 업데이트되었습니다.", data: { tagNumber, updatedFields: Object.keys(newData).filter( (key) => key !== "tagNumber" ), }, }; } catch (error) { // 예상치 못한 오류 처리 console.error("Unexpected error in updateFormDataInDB:", error); return { success: false, message: error instanceof Error ? `예상치 못한 오류가 발생했습니다: ${error.message}` : "알 수 없는 오류가 발생했습니다.", }; } } // FormColumn Type (동일) export interface FormColumn { key: string; type: string; label: string; options?: string[]; } interface MetadataResult { formName: string; formCode: string; columns: FormColumn[]; } /** * 서버 액션: * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서 * { formName, formCode, columns } 형태로 반환. * 없으면 null. */ export async function fetchFormMetadata( formCode: string ): Promise { try { // 기존 방식: select().from().where() const rows = await db .select() .from(formMetas) .where(eq(formMetas.formCode, formCode)) .limit(1); // rows는 배열 const metaData = rows[0]; if (!metaData) return null; return { formCode: metaData.formCode, formName: metaData.formName, columns: metaData.columns as FormColumn[], }; } catch (err) { console.error("Error in fetchFormMetadata:", err); return null; } } type GetReportFileList = ( packageId: string, formCode: string ) => Promise<{ formId: number; }>; export const getFormId: GetReportFileList = async (packageId, formCode) => { const result: { formId: number } = { formId: 0, }; try { const [targetForm] = await db .select() .from(forms) .where( and( eq(forms.formCode, formCode), eq(forms.contractItemId, Number(packageId)) ) ); if (!targetForm) { throw new Error("Not Found Target Form"); } const { id: formId } = targetForm; result.formId = formId; } catch (err) { } finally { return result; } }; type getReportTempList = ( packageId: number, formId: number ) => Promise; export const getReportTempList: getReportTempList = async ( packageId, formId ) => { let result: VendorDataReportTemps[] = []; try { result = await db .select() .from(vendorDataReportTemps) .where( and( eq(vendorDataReportTemps.contractItemId, packageId), eq(vendorDataReportTemps.formId, formId) ) ); } catch (err) { } finally { return result; } }; export async function uploadReportTemp( packageId: number, formId: number, formData: FormData ) { const file = formData.get("file") as File | null; const customFileName = formData.get("customFileName") as string; const uploaderType = (formData.get("uploaderType") as string) || "vendor"; if (!["vendor", "client", "shi"].includes(uploaderType)) { throw new Error( `Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi` ); } if (file && file.size > 0) { const originalName = customFileName; const ext = path.extname(originalName); const uniqueName = uuidv4() + ext; const baseDir = path.join( process.cwd(), "public", "vendorFormData", packageId.toString(), formId.toString() ); const savePath = path.join(baseDir, uniqueName); const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await fs.mkdir(baseDir, { recursive: true }); await fs.writeFile(savePath, buffer); return db.transaction(async (tx) => { // 파일 정보를 테이블에 저장 await tx .insert(vendorDataReportTemps) .values({ contractItemId: packageId, formId: formId, fileName: originalName, filePath: `/vendorFormData/${packageId.toString()}/${formId.toString()}/${uniqueName}`, }) .returning(); }); } } export const getOrigin = async (): Promise => { const headersList = await headers(); const host = headersList.get("host"); const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http const origin = `${proto}://${host}`; return origin; }; export const getReportTempFileData = async (): Promise<{ fileName: string; fileType: string; base64: string; }> => { const fileName = "sample_template_file.docx"; const tempFile = await fs.readFile( `public/vendorFormReportSample/${fileName}` ); return { fileName, fileType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", base64: tempFile.toString("base64"), }; }; type deleteReportTempFile = (id: number) => Promise<{ result: boolean; error?: any; }>; export const deleteReportTempFile: deleteReportTempFile = async (id) => { try { return db.transaction(async (tx) => { const [targetTempFile] = await tx .select() .from(vendorDataReportTemps) .where(eq(vendorDataReportTemps.id, id)); if (!targetTempFile) { throw new Error("해당 Template File을 찾을 수 없습니다."); } await tx .delete(vendorDataReportTemps) .where(eq(vendorDataReportTemps.id, id)); const { filePath } = targetTempFile; await fs.unlink("public" + filePath); return { result: true }; }); } catch (err) { return { result: false, error: (err as Error).message }; } };