import { z } from "zod"; import { db } from "@/lib/db"; import { logger } from "@/lib/logger"; import { ApiError } from "@/lib/errors"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants"; import type { Prisma } from "@/lib/generated/prisma/client"; import { calculateUpgradePrice, formatDate, getAccountTypeLabel, getAccountTypePrice, getTrialAccountTypeLabel, } from "@/lib/utils"; const pageSizeOptions = [10, 20, 50, 100] as const; const upgradeRequestSearchSchema = z.object({ page: z.coerce.number().int().min(1).catch(1), pageSize: z.coerce .number() .int() .refine((value) => pageSizeOptions.includes(value as PageSize)) .catch(10), q: z.string().trim().catch(""), status: z .union([z.literal("all"), z.coerce.number().int().min(0)]) .optional() .catch(REQUEST_STATUSES.REQUESTED), }); type PageSize = (typeof pageSizeOptions)[number]; export type RawSearchParams = | URLSearchParams | Record; export type UpgradeRequestSearchParams = z.infer< typeof upgradeRequestSearchSchema >; export type UpgradeRequestListItem = { maestroUpgradeID: number; maestroID: number; maestroName: string; registeredActivateStatus: number; registeredAccountType: number; requestedAccountType: number; requestedDateTime: string; availableActivateDateTime: string; status: number; additionalPrice: number; }; export type UpgradeRequestListResult = { items: UpgradeRequestListItem[]; page: number; pageSize: number; totalCount: number; totalPages: number; q: string; status?: number | "all"; }; export async function getUpgradeRequests( rawSearchParams: RawSearchParams = {} ): Promise { const t0 = Date.now(); const params = parseUpgradeRequestSearchParams(rawSearchParams); const where = buildUpgradeRequestWhere(params); const skip = (params.page - 1) * params.pageSize; const [totalCount, requests] = await Promise.all([ db.maestro_upgrade.count({ where }), db.maestro_upgrade.findMany({ where, orderBy: { MaestroUpgradeID: "desc" }, skip, take: params.pageSize, select: { MaestroUpgradeID: true, MaestroID: true, RegisteredActivateStatus: true, RegisteredAccountType: true, RequestedAccountType: true, RequestedDateTime: true, Status: true, maestro: { select: { Name: true, AvailableActivateDateTime: true, }, }, }, }), ]); logger.info("upgrade-requests", "list fetched", { totalCount, page: params.page, duration: Date.now() - t0, }); return { items: requests.map(toUpgradeRequestListItem), page: params.page, pageSize: params.pageSize, totalCount, totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)), q: params.q, status: params.status, }; } export async function approveUpgradeRequest( maestroUpgradeID: number ): Promise<{ availableActivateDateTime: string; maestroName: string; maestroEmail: string; registeredActivateStatus: number; registeredAccountType: number; requestedAccountType: number; }> { return db.$transaction(async (tx) => { // Atomically claim the request — prevents concurrent double-approval const claimed = await tx.maestro_upgrade.updateMany({ where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: REQUEST_STATUSES.APPLIED }, }); if (claimed.count === 0) { const exists = await tx.maestro_upgrade.count({ where: { MaestroUpgradeID: maestroUpgradeID }, }); throw new ApiError( exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.", exists ? 409 : 404 ); } const request = await tx.maestro_upgrade.findUnique({ where: { MaestroUpgradeID: maestroUpgradeID }, select: { MaestroID: true, RequestedAccountType: true, RegisteredActivateStatus: true, RegisteredAccountType: true, maestro: { select: { Name: true, Email: true } }, }, }); const availableActivateDateTime = calculateUpgradeAvailableDate(); await tx.maestro.update({ where: { MaestroID: request!.MaestroID }, data: { AccountType: request!.RequestedAccountType, ActivateStatus: ACTIVATE_STATUSES.ACTIVE, AvailableActivateDateTime: availableActivateDateTime, }, }); await tx.maestro_upgrade.updateMany({ where: { MaestroID: request!.MaestroID, Status: REQUEST_STATUSES.REQUESTED, }, data: { Status: REQUEST_STATUSES.CLOSED }, }); await tx.maestro_log.create({ data: { Type: "upgrade_maestro", MaestroID: request!.MaestroID, LogDateTime: new Date(), Remark: buildUpgradeLogRemark( request!.RegisteredActivateStatus, request!.RegisteredAccountType, request!.RequestedAccountType ), }, }); return { availableActivateDateTime: availableActivateDateTime.toISOString(), maestroName: request!.maestro.Name.trim(), maestroEmail: request!.maestro.Email.trim(), registeredActivateStatus: request!.RegisteredActivateStatus, registeredAccountType: request!.RegisteredAccountType, requestedAccountType: request!.RequestedAccountType, }; }); } export async function createUpgradeRequest( maestroID: number, requestedAccountType: number ): Promise<{ maestroUpgradeID: number; registeredAccountType: number; requestedDateTime: string; status: number }> { return db.$transaction(async (tx) => { const maestro = await tx.maestro.findUnique({ where: { MaestroID: maestroID }, select: { ActivateStatus: true, AccountType: true }, }); if (!maestro) { throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); } if (requestedAccountType <= maestro.AccountType) { throw new ApiError("현재 요금제보다 높은 요금제로만 업그레이드 신청이 가능합니다.", 400); } const request = await tx.maestro_upgrade.create({ data: { MaestroID: maestroID, RegisteredActivateStatus: maestro.ActivateStatus, RegisteredAccountType: maestro.AccountType, RequestedAccountType: requestedAccountType, RequestedDateTime: new Date(), Status: REQUEST_STATUSES.REQUESTED, }, select: { MaestroUpgradeID: true, RegisteredAccountType: true, RequestedDateTime: true, Status: true, }, }); return { maestroUpgradeID: request.MaestroUpgradeID, registeredAccountType: request.RegisteredAccountType, requestedDateTime: request.RequestedDateTime.toISOString(), status: request.Status, }; }); } export async function rejectUpgradeRequest( maestroUpgradeID: number ): Promise { await db.$transaction(async (tx) => { const claimed = await tx.maestro_upgrade.updateMany({ where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: REQUEST_STATUSES.CLOSED }, }); if (claimed.count === 0) { const exists = await tx.maestro_upgrade.count({ where: { MaestroUpgradeID: maestroUpgradeID }, }); throw new ApiError( exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.", exists ? 409 : 404 ); } const request = await tx.maestro_upgrade.findUnique({ where: { MaestroUpgradeID: maestroUpgradeID }, select: { MaestroID: true, RegisteredActivateStatus: true, RegisteredAccountType: true, RequestedAccountType: true, }, }); await tx.maestro_log.create({ data: { Type: "reject_upgrade_maestro", MaestroID: request!.MaestroID, LogDateTime: new Date(), Remark: buildUpgradeLogRemark( request!.RegisteredActivateStatus, request!.RegisteredAccountType, request!.RequestedAccountType ), }, }); }); } export function parseUpgradeRequestSearchParams( rawSearchParams: RawSearchParams ): UpgradeRequestSearchParams { return upgradeRequestSearchSchema.parse( searchParamsToRecord(rawSearchParams) ); } function buildUpgradeRequestWhere( params: UpgradeRequestSearchParams ): Prisma.maestro_upgradeWhereInput { const conditions: Prisma.maestro_upgradeWhereInput[] = []; if (params.q) { const keywordConditions: Prisma.maestro_upgradeWhereInput[] = [ { maestro: { Name: { contains: params.q } } }, ]; const numericQuery = Number(params.q); if (Number.isInteger(numericQuery) && numericQuery > 0) { keywordConditions.unshift( { MaestroUpgradeID: numericQuery }, { MaestroID: numericQuery } ); } conditions.push({ OR: keywordConditions }); } if (typeof params.status === "number" && Number.isInteger(params.status)) { conditions.push({ Status: params.status }); } return conditions.length > 0 ? { AND: conditions } : {}; } function searchParamsToRecord( rawSearchParams: RawSearchParams ): Record { if (rawSearchParams instanceof URLSearchParams) { return Object.fromEntries(rawSearchParams.entries()); } return Object.fromEntries( Object.entries(rawSearchParams).map(([key, value]) => [ key, Array.isArray(value) ? value[0] : value, ]) ); } function toUpgradeRequestListItem(request: { MaestroUpgradeID: number; MaestroID: number; RegisteredActivateStatus: number; RegisteredAccountType: number; RequestedAccountType: number; RequestedDateTime: Date; Status: number; maestro: { Name: string; AvailableActivateDateTime: Date; }; }): UpgradeRequestListItem { return { maestroUpgradeID: request.MaestroUpgradeID, maestroID: request.MaestroID, maestroName: request.maestro.Name.trim(), registeredActivateStatus: request.RegisteredActivateStatus, registeredAccountType: request.RegisteredAccountType, requestedAccountType: request.RequestedAccountType, requestedDateTime: formatDate(request.RequestedDateTime), availableActivateDateTime: request.maestro.AvailableActivateDateTime.toISOString(), status: request.Status, additionalPrice: calculateAdditionalPrice( request.RegisteredActivateStatus, request.RegisteredAccountType, request.RequestedAccountType ), }; } function calculateAdditionalPrice( registeredActivateStatus: number, registeredAccountType: number, requestedAccountType: number ) { if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) { return getAccountTypePrice(requestedAccountType); } return calculateUpgradePrice(requestedAccountType, registeredAccountType); } function calculateUpgradeAvailableDate(now = new Date()) { const availableDate = new Date(now); availableDate.setFullYear(availableDate.getFullYear() + 1); return availableDate; } function buildUpgradeLogRemark( registeredActivateStatus: number, registeredAccountType: number, requestedAccountType: number ) { const registeredLabel = registeredActivateStatus === ACTIVATE_STATUSES.TRIAL ? getTrialAccountTypeLabel(registeredAccountType) : getAccountTypeLabel(registeredAccountType); return `${registeredLabel} -> ${getAccountTypeLabel(requestedAccountType)}`.slice( 0, 100 ); }