"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import { tasks, type Task } from "@/db/schema/tasks"; import { customAlphabet } from "nanoid"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import type { CreateTaskSchema, UpdateTaskSchema, GetTasksSchema } from "./validations"; import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; // 레포지토리 함수들 import { selectTasks, countTasks, insertTask, insertTasks, selectOldestTaskExcept, deleteTaskById, deleteTasksByIds, deleteAllTasks, updateTask, updateTasks, groupByStatus, groupByPriority, getAllTasks, } from "./repository"; import ExcelJS from "exceljs" import { tasksColumnsConfig, type TaskColumnConfig } from "@/config/tasksColumnsConfig" interface ImportResult { errorFile: File | null errorMessage: string | null successMessage?: string } /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ /** * 복잡한 조건으로 Task 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getTasks(input: GetTasksSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; const fromDate = input.from ? new Date(input.from) : undefined; const toDate = input.to ? new Date(input.to) : undefined; // const advancedTable = input.flags.includes("advancedTable"); const advancedTable = true; // advancedTable 모드면 filterColumns()로 where 절 구성 const advancedWhere = filterColumns({ table: tasks, filters: input.filters, joinOperator: input.joinOperator, }); let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or(ilike(tasks.title, s), ilike(tasks.code, s) , ilike(tasks.status, s) ) // 필요시 여러 칼럼 OR조건 (status, priority, etc) } const finalWhere = and( // advancedWhere or your existing conditions advancedWhere, globalWhere // and()함수로 결합 or or() 등으로 결합 ) // 아니면 ilike, inArray, gte 등으로 where 절 구성 const where = advancedTable ? finalWhere : and( input.title ? ilike(tasks.title, `%${input.title}%`) : undefined, input.status.length > 0 ? inArray(tasks.status, input.status) : undefined, input.priority.length > 0 ? inArray(tasks.priority, input.priority) : undefined, fromDate ? gte(tasks.createdAt, fromDate) : undefined, toDate ? lte(tasks.createdAt, toDate) : undefined ); const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(tasks[item.id]) : asc(tasks[item.id]) ) : [asc(tasks.createdAt)]; // 트랜잭션 내부에서 Repository 호출 const { data, total } = await db.transaction(async (tx) => { const data = await selectTasks(tx, { where, orderBy, offset, limit: input.perPage, }); const total = await countTasks(tx, where); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); // console.log("===> advancedWhere:", advancedWhere); // console.log("===> globalWhere:", globalWhere); // console.log("===> finalWhere:", finalWhere); // console.log("===> offset:", offset, " limit:", input.perPage); return { data, pageCount }; } catch (err) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], // 캐싱 키 { revalidate: 3600, tags: ["tasks"], // revalidateTag("tasks") 호출 시 무효화 } )(); } /** Status별 개수 */ export async function getTaskStatusCounts() { return unstable_cache( async () => { try { const initial: Record = { todo: 0, "in-progress": 0, done: 0, canceled: 0, }; const result = await db.transaction(async (tx) => { const rows = await groupByStatus(tx); return rows.reduce>((acc, { status, count }) => { acc[status] = count; return acc; }, initial); }); return result; } catch (err) { return {} as Record; } }, ["task-status-counts"], // 캐싱 키 { revalidate: 3600, } )(); } /** Priority별 개수 */ export async function getTaskPriorityCounts() { return unstable_cache( async () => { try { const initial: Record = { low: 0, medium: 0, high: 0, }; const result = await db.transaction(async (tx) => { const rows = await groupByPriority(tx); return rows.reduce>((acc, { priority, count }) => { acc[priority] = count; return acc; }, initial); }); return result; } catch (err) { return {} as Record; } }, ["task-priority-counts"], { revalidate: 3600, } )(); } /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ /** * Task 생성 후, (가장 오래된 Task 1개) 삭제로 * 전체 Task 개수를 고정 */ export async function createTask(input: CreateTaskSchema) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // 새 Task 생성 const [newTask] = await insertTask(tx, { title: input.title, status: input.status, label: input.label, priority: input.priority, }); return newTask; }); console.log("tasks") // 캐시 무효화 revalidateTag("tasks"); revalidateTag("task-status-counts"); revalidateTag("task-priority-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 3) 업데이트 ----------------------------------------------------- */ /** 단건 업데이트 */ export async function modifiTask(input: UpdateTaskSchema & { id: string }) { unstable_noStore(); try { const data = await db.transaction(async (tx) => { const [res] = await updateTask(tx, input.id, { title: input.title, label: input.label, status: input.status, priority: input.priority, }); return res; }); revalidateTag("tasks"); if (data.status === input.status) { revalidateTag("task-status-counts"); } if (data.priority === input.priority) { revalidateTag("task-priority-counts"); } return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 업데이트 */ export async function modifiTasks(input: { ids: string[]; label?: Task["label"]; status?: Task["status"]; priority?: Task["priority"]; }) { unstable_noStore(); try { const data = await db.transaction(async (tx) => { const [res] = await updateTasks(tx, input.ids, { label: input.label, status: input.status, priority: input.priority, }); return res; }); revalidateTag("tasks"); if (data.status === input.status) { revalidateTag("task-status-counts"); } if (data.priority === input.priority) { revalidateTag("task-priority-counts"); } return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /* ----------------------------------------------------- 4) 삭제 ----------------------------------------------------- */ /** 단건 삭제 */ export async function removeTask(input: { id: string }) { unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 await deleteTaskById(tx, input.id); // 바로 새 Task 생성 }); revalidateTag("tasks"); revalidateTag("task-status-counts"); revalidateTag("task-priority-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 삭제 */ export async function removeTasks(input: { ids: string[] }) { unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 await deleteTasksByIds(tx, input.ids); }); revalidateTag("tasks"); revalidateTag("task-status-counts"); revalidateTag("task-priority-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** * 1) 그룹 헤더(2줄 헤더)인지, 1줄 헤더인지 판별 * - Row1이 `group` 값들로 이루어져 있고, * - Row2가 `excelHeader` 값들과 매칭되는지 */ function detectHasGroupHeader( worksheet: ExcelJS.Worksheet, config: TaskColumnConfig[] ): boolean { // 전체 group 목록 const groupSet = new Set( config.filter((c) => c.group).map((c) => c.group!.trim()) ) // 전체 excelHeader 목록 const headerSet = new Set( config.filter((c) => c.excelHeader).map((c) => c.excelHeader!.trim()) ) // row1이 전부(또는 대부분) groupSet에 속하면 => 그룹 헤더일 가능성 높음 // row1Values = (index 0은 비어있을 수 있으므로) 안전하게 string 변환 후 trim const row1Values = (worksheet.getRow(1)?.values ?? []) as (string | null | undefined)[] const row2Values = (worksheet.getRow(2)?.values ?? []) as (string | null | undefined)[] // row1Values가 전부 groupSet 내에 있거나 빈 문자열이면, "이건 그룹 헤더" const row1IsMostlyGroup = row1Values.every((val) => { if (!val) { return true } return groupSet.has(val.trim()) }) // row2 중에 headerSet에 포함되는 값이 몇 개나 되는가? // 즉, row2가 실제로 excelHeader로 구성되어 있으면 -> 2줄 헤더 가능성 const row2HeaderCount = row2Values.filter((val) => { // val이 string인지 확인 if (typeof val === "string") { return headerSet.has(val.trim()) } // null/undefined(또는 숫자, 객체 등)이면 필터링 제외 return false }).length // (단순 로직) row1이 그룹 같고, row2가 적어도 1개 이상 excelHeader 매칭 => 2줄 헤더 // 프로젝트에 맞춰 좀 더 세밀하게 조건을 잡아도 됨. if (row1IsMostlyGroup && row2HeaderCount > 0) { return true } return false } export async function importTasksExcel(file: File): Promise { try { // 1) 엑셀 로드 const buffer = await file.arrayBuffer() const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(buffer) // 첫 번째 시트만 사용 const worksheet = workbook.worksheets[0] if (!worksheet) { throw new Error("엑셀 파일에 시트가 없습니다.") } // 2) 그룹 헤더(2줄) or 일반 헤더(1줄) 판별 const hasGroupHeader = detectHasGroupHeader(worksheet, tasksColumnsConfig) const headerRowIndex = hasGroupHeader ? 2 : 1 const dataStartRowIndex = hasGroupHeader ? 3 : 2 const headerRow = worksheet.getRow(headerRowIndex) if (!headerRow) { throw new Error("엑셀 헤더 행을 찾지 못했습니다.") } // 3) 엑셀 헤더(문자열) → 컬럼 인덱스(Map) const columnIndexMap = new Map() headerRow.eachCell((cell, colIndex) => { if (typeof cell.value === "string") { columnIndexMap.set(cell.value.trim(), colIndex) } }) // 4) columnToFieldMap: "엑셀 열 인덱스" → "DB 필드(Task의 keyof)" // 예) "Code" → "code", "Title" → "title", ... const columnToFieldMap = new Map() tasksColumnsConfig.forEach((cfg) => { if (!cfg.excelHeader) return const colIndex = columnIndexMap.get(cfg.excelHeader.trim()) if (colIndex !== undefined) { // 예: colIndex=1 -> cfg.id="code" columnToFieldMap.set(colIndex, cfg.id) } }) // 5) 에러가 발생하면 표시할 용도 const errorRows: { rowIndex: number; message: string }[] = [] // 6) 엑셀에서 읽어온 행 데이터를 임시 보관 // "마지막 컬럼(D/d) → toDelete=true" type ExcelRowData = { rowIndex: number fields: Partial toDelete: boolean } const rowDataList: ExcelRowData[] = [] for (let r = dataStartRowIndex; r <= worksheet.rowCount; r++) { const row = worksheet.getRow(r) if (!row ) continue // (6-1) 마지막 셀을 보고 DELETE 여부 판단 const lastCellValue = row.getCell(row.cellCount).value const isDelete = typeof lastCellValue === "string" && lastCellValue.toLowerCase() === "d" // (6-2) 각 열 -> DB 필드 매핑 const fields = {} as Partial columnToFieldMap.forEach((fieldId, colIdx) => { const cellValue = row.getCell(colIdx).value if (fieldId === "createdAt") { fields.createdAt = undefined } else { fields[fieldId] = (cellValue ?? null) as any } }) rowDataList.push({ rowIndex: r, fields, toDelete: isDelete, }) } // (6-3) 혹시 이 시점에서 "필수 값이 누락됐다" 등의 검증을 하고 싶다면 errorRows.push(...) // if (errorRows.length > 0) => 엑셀에 표시 후 리턴 (생략) // 7) 현재 DB에 있는 "code" 목록을 가져온다 const existingCodes = await getAllTasks().then((rows) => rows.map((r) => r.code)) const existingCodeSet = new Set(existingCodes.filter(Boolean)) // 8) CREATE/UPDATE/DELETE 목록 분리 const toCreate: Task[] = [] // (updateTasks 함수가 "ids: string[], data: Partial" 형태) // - 여러 code를 한꺼번에 업데이트할 수도 있지만, 여기선 간단히 1code씩 const toUpdate: { codes: string[]; data: Partial }[] = [] const toDeleteCodes: string[] = [] for (const { rowIndex, fields, toDelete } of rowDataList) { // code를 string으로 캐스팅 const code = fields.code ? String(fields.code).trim() : "" if (toDelete) { // DELETE if (code && existingCodeSet.has(code)) { toDeleteCodes.push(code) } // code가 없거나 DB에 없으면 무시 continue } // CREATE or UPDATE if (!code) { toCreate.push(fields as Task) } else { // code가 있고, DB에도 있으면 UPDATE if (existingCodeSet.has(code)) { toUpdate.push({ codes: [code], data: fields }) } else { // code가 있지만 DB에 없으면 CREATE toCreate.push(fields as Task) } } } // (선택) 에러가 있으면 여기서 다시 한 번 errorRows에 추가 후 반환 가능 // 9) 트랜잭션으로 처리 await db.transaction(async (tx) => { // CREATE if (toCreate.length > 0) { await insertTasks(tx, toCreate) } // UPDATE if (toUpdate.length > 0) { for (const { codes, data } of toUpdate) { await updateTasks(tx, codes, data) } } // DELETE if (toDeleteCodes.length > 0) { await deleteTasksByIds(tx, toDeleteCodes) } }) // 10) 성공 메시지 const msg: string[] = [] if (toCreate.length > 0) msg.push(`${toCreate.length}건 생성`) if (toUpdate.length > 0) msg.push(`${toUpdate.length}건 수정`) if (toDeleteCodes.length > 0) msg.push(`${toDeleteCodes.length}건 삭제`) const successMessage = msg.length > 0 ? msg.join(", ") : "No changes" return { errorFile: null, errorMessage: null, successMessage, } } catch (err: any) { return { errorFile: null, errorMessage: err.message || "Import 중 오류가 발생했습니다.", } } }