"use server"; import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; import logger from '@/lib/logger'; import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 import { type Company } from "@/db/schema/companies"; // User 테이블 import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; // 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 import { selectUsersWithCompanyAndRoles, countUsers, insertUser, insertUserRole, updateUser, deleteRolesByUserId, deleteRolesByUserIds, deleteUserById, deleteUsersByIds, groupByCompany, groupByRole, findAllCompanies, getUserById, updateUsers, findAllRoles } from "./repository"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; // types import type { CreateUserSchema, UpdateUserSchema, GetUsersSchema } from "./validations"; import { sendEmail } from "@/lib//mail/sendEmail"; import { Vendor } from "@/db/schema/vendors"; /** * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getUsers(input: GetUsersSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage; // (1) advancedWhere const advancedWhere = filterColumns({ table: userView, filters: input.filters, joinOperator: input.joinOperator, }); // (2) globalWhere let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( ilike(userView.user_name, s), ilike(userView.user_email, s), ilike(userView.company_name, s) ); } // (3) 디폴트 domainWhere = eq(userView.domain, "partners") // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X let domainWhere; const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); if (!hasDomainFilter) { domainWhere = eq(userView.user_domain, "partners"); } // (4) 최종 where const finalWhere = and(advancedWhere, globalWhere, domainWhere); // (5) 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(userView[item.id]) : asc(userView[item.id]) ) : [desc(users.createdAt)]; // ... const { data, total } = await db.transaction(async (tx) => { const data = await selectUsersWithCompanyAndRoles(tx, { where: finalWhere, orderBy, offset, limit: input.perPage, }); const total = await countUsers(tx, finalWhere); return { data, total }; }); const pageCount = Math.ceil(total / input.perPage); return { data, pageCount }; } catch (err) { return { data: [], pageCount: 0 }; } }, [JSON.stringify(input)], { revalidate: 3600, tags: ["users"], } )(); } export async function findUserById(id: number) { try { logger.info({ id }, 'Fetching user by ID'); const user = await getUserById(id); if (!user) { logger.warn({ id }, 'User not found'); } else { logger.debug({ user }, 'User fetched successfully'); } return user; } catch (error) { logger.error({ error }, 'Error fetching user by ID'); throw new Error('Failed to fetch user'); } }; /** * User 생성 * 필요 시 companyId, roles, etc. 함께 처리 */ // export async function createUser(input: CreateUserSchema & { language?: string }) { // unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) // try { // const userLang = input.language || "en"; // 클라이언트가 안 주면 기본 "en" // // 예시 subject 분기 // const subject = // userLang === "ko" // ? "[eVCP] 어드민 계정이 생성되었습니다." // : "[eVCP] Admin Account Created"; // const loginUrl = // userLang === "ko" // ? "http://3.36.56.124:3000/ko/login" // : "http://3.36.56.124:3000/en/login"; // // 실제 sendEmail // await sendEmail({ // to: input.email, // subject, // template: "admin-created", // context: { // name: input.name, // loginUrl, // 위에서 분기한 URL // language: userLang, // 템플릿에서 {{t ... lng=language}} 처럼 쓸 수도 // }, // }); // await db.transaction(async (tx) => { // // insertUser는 단건 생성 // const [newUser] = await insertUser(tx, { // name: input.name, // email: input.email, // domain: input.domain, // companyId: input.companyId ?? null, // // 기타 필요한 필드 // }); // // 만약 roles를 함께 생성하려면, // await insertUserRole(tx, { userId: newUser.id, roleId: Number(r) }); // } // }); // // 캐시 무효화 // revalidateTag("users"); // revalidateTag("user-company-counts"); // return { data: null, error: null }; // } catch (err) { // return { data: null, error: getErrorMessage(err) }; // } // } export async function createAdminUser(input: CreateUserSchema & { language?: string }) { unstable_noStore(); // Next.js 캐싱 방지 try { // 예) 관리자 메일 알림 로직 // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신 // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다. const userLang = input.language || "en"; const subject = userLang === "ko" ? "[eVCP] 어드민 계정이 생성되었습니다." : "[eVCP] Admin Account Created"; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' const loginUrl = userLang === "ko" ? `${baseUrl}/ko/partners` : `${baseUrl}/en/partners`; await sendEmail({ to: input.email, subject, template: "admin-created", // 예: nodemailer + handlebars 등 context: { name: input.name, loginUrl, language: userLang, }, }); // 트랜잭션 시작 await db.transaction(async (tx) => { // 1. 먼저 roles 테이블에서 name = "Vendor Admin" AND domain = input.domain 인 것을 찾는다. let [vendorAdminRole] = await tx .select() .from(roles) .where( and( eq(roles.name, "Vendor Admin"), eq(roles.domain, input.domain), eq(roles.companyId, input.companyId as number), ) ) .limit(1); // 2. 만약 없다면, 새롭게 생성한다. if (!vendorAdminRole) { // companyId나 description 등은 필요에 따라 조정 const insertedRoles = await tx .insert(roles) .values({ name: "Vendor Admin", domain: input.domain, companyId: input.companyId ?? null, description: "Auto created Vendor Admin role", }) .returning(); vendorAdminRole = insertedRoles[0]; // 방금 insert한 row } // 3. 유저 생성 const [newUser] = await insertUser(tx, { name: input.name, email: input.email, domain: input.domain, companyId: input.companyId ?? null, // 기타 필요한 필드 추가 }); // 4. Vendor Admin role을 user_roles 에 할당 (반복문 없이 단일 insert) await insertUserRole(tx, { userId: newUser.id, roleId: vendorAdminRole.id, // Number()로 캐스팅할 필요 없이 정수로 관리한다고 가정 }); }); // 캐시 무효화 revalidateTag("users"); revalidateTag("user-company-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** * 회사별 유저 개수 groupBy */ export async function getUserCountGroupByCompany() { return unstable_cache( async () => { try { // 예: { [companyId: number]: number } const result = await db.transaction(async (tx) => { const rows = await groupByCompany(tx); // groupByCompany(tx): SELECT companyId, COUNT(*) FROM users GROUP BY companyId HAVING COUNT(*) > 0 // 예: [{ companyId: 1, count: 10 }, { companyId: 2, count: 3 }, ...] // reduce해서 {1: 10, 2: 3, ...} 형태로 만들거나 그대로 반환할 수 있음 const obj: Record = {}; for (const row of rows) { if (row.companyId !== null) { obj[row.companyId] = row.count; } else { // companyId가 null인 유저 수 obj[-1] = (obj[-1] ?? 0) + row.count; } } return obj; }); return result; } catch (err) { return {}; } }, ["user-company-counts"], { revalidate: 3600, } )(); } /** * 롤별 유저 개수 groupBy */ export async function getUserCountGroupByRole() { return unstable_cache( async () => { try { const result = await db.transaction(async (tx) => { const rows = await groupByRole(tx); const obj: Record = {}; for (const row of rows) { if (row.roleId !== null) { obj[row.roleId] = row.count; } else { // roleId가 null인 유저 수 obj[-1] = (obj[-1] ?? 0) + row.count; } } return obj; }); // 여기서 result를 반환해 줘야 함! return result; } catch (err) { console.error("getUserCountGroupByRole error:", err); return {}; } }, ["user-role-counts"], { revalidate: 3600, } )(); } /** * 단건 업데이트 */ export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) { unstable_noStore(); try { const oldUser = await getUserById(input.id) const oldEmail = oldUser?.user_email ?? null; const data = await db.transaction(async (tx) => { // 1) 먼저 User 테이블 업데이트 const [res] = await updateUser(tx, input.id, { name: input.name, companyId: input.companyId, email: input.email, }); // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입 if (input.roles) { // 기존 roles 삭제 await deleteRolesByUserId(tx, input.id); // 새 roles 삽입 for (const r of input.roles) { await insertUserRole(tx, { userId: input.id, roleId: Number(r), }); } } return res; }); // 3) 캐시 무효화 revalidateTag("users"); // 4) 이메일이 변경되었고, roles 중에 "admin"이 있다면 → 메일 발송 const isEmailChanged = oldEmail && input.email && oldEmail !== input.email; const hasAdminRole = input.roles?.includes("admin") ?? false; if (isEmailChanged && hasAdminRole && input.email) { await sendEmail({ to: input.email, subject: "[EVCP] Admin Email Changed", template: "admin-email-changed", context: { name: input.name, oldEmail, newEmail: input.email, language: input.language ?? "en", }, }); } // 예: companyId 변경 시 회사별 count도 다시 계산해야 하는 경우 if (data.companyId === input.companyId) { revalidateTag("user-company-counts"); } return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** 복수 업데이트 */ export async function modifiUsers(input: { ids: number[]; // 업데이트 대상 유저 ID 배열 companyId?: User["companyId"]; // 회사 ID (있으면 업데이트) roles?: UserView["roles"]; // 새 roles 배열 (있으면 업데이트) }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 try { await db.transaction(async (tx) => { // 1) 회사 정보 업데이트 if (typeof input.companyId !== "undefined") { // companyId가 주어졌으면, 해당 사용자들의 companyId 변경 await updateUsers(tx, input.ids, { companyId: input.companyId }) } // 2) roles 업데이트 // (있으면 기존 roles 삭제 → 새 roles 삽입) if (Array.isArray(input.roles)) { // (a) 기존 roles 전부 삭제 await deleteRolesByUserIds(tx, input.ids) // (b) 새 roles 삽입 for (const userId of input.ids) { for (const r of input.roles) { await insertUserRole(tx, { userId, roleId: Number(r), }) } } } }) // 캐시 무효화 revalidateTag("users") return { data: null, error: null } } catch (err) { return { data: null, error: getErrorMessage(err) } } } /** * 단건 삭제 */ export async function removeUser(input: { id: number }) { unstable_noStore(); try { await db.transaction(async (tx) => { // 유저 삭제 await deleteRolesByUserId(tx, input.id); await deleteUserById(tx, input.id); // roles, otps 등도 함께 삭제해야 하면 여기서 처리 }); // 캐시 무효화 revalidateTag("users"); revalidateTag("user-company-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } /** * 복수 삭제 */ export async function removeUsers(input: { ids: number[] }) { unstable_noStore(); try { await db.transaction(async (tx) => { // user_roles도 있으면 먼저 삭제해야 할 수 있음 await deleteRolesByUserIds(tx, input.ids); await deleteUsersByIds(tx, input.ids); }); revalidateTag("users"); revalidateTag("user-company-counts"); return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; } } export async function getAllCompanies(): Promise { try { return await findAllCompanies(); // Company[] } catch (err) { throw new Error("Failed to get companies"); } } export async function getAllRoles(): Promise { try { return await findAllRoles(); } catch (err) { throw new Error("Failed to get roles"); } } /** * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수 * @param email 확인할 이메일 * @returns boolean - 존재하면 true, 없으면 false */ export async function checkEmailExists(email: string): Promise { const result = await db .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만 .from(users) .where(eq(users.email, email)) .limit(1); return result.length > 0; // 1건 이상 있으면 true }