diff options
Diffstat (limited to 'lib/tasks/service.ts')
| -rw-r--r-- | lib/tasks/service.ts | 561 |
1 files changed, 561 insertions, 0 deletions
diff --git a/lib/tasks/service.ts b/lib/tasks/service.ts new file mode 100644 index 00000000..c31ecd4b --- /dev/null +++ b/lib/tasks/service.ts @@ -0,0 +1,561 @@ +// src/lib/tasks/service.ts +"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<Task["status"], number> = { + todo: 0, + "in-progress": 0, + done: 0, + canceled: 0, + }; + + + const result = await db.transaction(async (tx) => { + const rows = await groupByStatus(tx); + return rows.reduce<Record<Task["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Task["status"], number>; + } + }, + ["task-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/** Priority별 개수 */ +export async function getTaskPriorityCounts() { + return unstable_cache( + async () => { + try { + + const initial: Record<Task["priority"], number> = { + low: 0, + medium: 0, + high: 0, + }; + + const result = await db.transaction(async (tx) => { + const rows = await groupByPriority(tx); + return rows.reduce<Record<Task["priority"], number>>((acc, { priority, count }) => { + acc[priority] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Task["priority"], number>; + } + }, + ["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<ImportResult> { + 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<string, number>() + 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<number, keyof Task>() + 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<Task> + 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<Task> + + 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<string>(existingCodes.filter(Boolean)) + + // 8) CREATE/UPDATE/DELETE 목록 분리 + const toCreate: Task[] = [] + // (updateTasks 함수가 "ids: string[], data: Partial<Task>" 형태) + // - 여러 code를 한꺼번에 업데이트할 수도 있지만, 여기선 간단히 1code씩 + const toUpdate: { codes: string[]; data: Partial<Task> }[] = [] + 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 중 오류가 발생했습니다.", + } + } +}
\ No newline at end of file |
