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 } from "@/lib/constants"; import type { Prisma } from "@/lib/generated/prisma/client"; import { formatDate } from "@/lib/utils"; const pageSizeOptions = [10, 20, 50, 100] as const; const sortOptions = [ "id-desc", "id-asc", "joinedAt-desc", "joinedAt-asc", "availableAt-desc", "availableAt-asc", ] as const; const maestroListSearchSchema = 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(""), activateStatus: z .union([z.literal("all"), z.coerce.number().int().min(0)]) .optional() .catch(undefined), accountType: z .union([z.literal("all"), z.coerce.number().int().min(0)]) .optional() .catch(undefined), sort: z.enum(sortOptions).catch("id-desc"), }); type PageSize = (typeof pageSizeOptions)[number]; export type MaestroSort = (typeof sortOptions)[number]; export type MaestroListSearchParams = z.infer; export type RawSearchParams = | URLSearchParams | Record; export type MaestroListItem = { maestroID: number; name: string; email: string; accountType: number; activateStatus: number; availableActivateDateTime: string; acceptClausesDateTime: string; playerCount: number; }; export type MaestroListResult = { items: MaestroListItem[]; page: number; pageSize: number; totalCount: number; totalPages: number; q: string; activateStatus?: number | "all"; accountType?: number | "all"; sort: MaestroSort; }; export type MaestroDetail = { maestro: MaestroListItem & { allowEditEnterCode: number; maestroTestID: number | null; }; counts: { players: number; extensionRequests: number; upgradeRequests: number; logs: number; }; players: Array<{ playerID: number; name: string; enterCode: string; accountType: number | null; }>; extensionRequests: Array<{ maestroExtensionID: number; accountType: number; requestedDateTime: string; status: number; }>; upgradeRequests: Array<{ maestroUpgradeID: number; registeredActivateStatus: number; registeredAccountType: number; requestedAccountType: number; requestedDateTime: string; status: number; }>; logs: Array<{ maestroLogID: number; type: string; logDateTime: string; remark: string; }>; }; const activateStatusValues = [ ACTIVATE_STATUSES.PENDING, ACTIVATE_STATUSES.TRIAL, ACTIVATE_STATUSES.ACTIVE, ACTIVATE_STATUSES.CANCELED, ] as const; export const updateMaestroSchema = z.object({ name: z.string().trim().min(1).max(50).optional(), email: z.string().trim().email().max(50).optional(), activateStatus: z.coerce .number() .int() .refine((v) => (activateStatusValues as readonly number[]).includes(v)) .optional(), accountType: z.coerce .number() .int() .refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v)) .optional(), availableActivateDateTime: z.string().min(1).optional(), allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(), }); export type UpdateMaestroPayload = z.infer; export async function updateMaestro( maestroID: number, payload: UpdateMaestroPayload ): Promise<{ changed: boolean } | null> { const t0 = Date.now(); const maestro = await db.maestro.findUnique({ where: { MaestroID: maestroID }, select: { Name: true, Email: true, ActivateStatus: true, AccountType: true, AvailableActivateDateTime: true, AllowEditEnterCode: true, }, }); if (!maestro) return null; if (payload.name !== undefined && payload.name !== maestro.Name.trim()) { const conflict = await db.maestro.findFirst({ where: { Name: payload.name, NOT: { MaestroID: maestroID } }, select: { MaestroID: true }, }); if (conflict) { throw new ApiError("이미 사용 중인 이름입니다.", 409); } } const updates: { Name?: string; Email?: string; ActivateStatus?: number; AccountType?: number; AvailableActivateDateTime?: Date; AllowEditEnterCode?: number; } = {}; const logs: Array<{ type: string; remark: string }> = []; if (payload.name !== undefined && payload.name !== maestro.Name.trim()) { updates.Name = payload.name; logs.push({ type: "update_maestro_name", remark: `${maestro.Name.trim()} -> ${payload.name}`.slice(0, 100), }); } if (payload.email !== undefined && payload.email !== maestro.Email.trim()) { updates.Email = payload.email; logs.push({ type: "update_maestro_email", remark: `${maestro.Email.trim()} -> ${payload.email}`.slice(0, 100), }); } if ( payload.activateStatus !== undefined && payload.activateStatus !== maestro.ActivateStatus ) { updates.ActivateStatus = payload.activateStatus; logs.push({ type: "update_maestro_status", remark: `${maestro.ActivateStatus} -> ${payload.activateStatus}`, }); } if ( payload.accountType !== undefined && payload.accountType !== maestro.AccountType ) { updates.AccountType = payload.accountType; logs.push({ type: "update_maestro_account_type", remark: `${maestro.AccountType} -> ${payload.accountType}`, }); } if (payload.availableActivateDateTime !== undefined) { const newDate = new Date(payload.availableActivateDateTime); if (!isNaN(newDate.getTime())) { const prevMin = Math.floor( maestro.AvailableActivateDateTime.getTime() / 60000 ); const newMin = Math.floor(newDate.getTime() / 60000); if (prevMin !== newMin) { updates.AvailableActivateDateTime = newDate; const prevStr = formatDate(maestro.AvailableActivateDateTime); const newStr = formatDate(newDate); logs.push({ type: "update_maestro_available_date", remark: `${prevStr} -> ${newStr}`, }); } } } if ( payload.allowEditEnterCode !== undefined && payload.allowEditEnterCode !== maestro.AllowEditEnterCode ) { updates.AllowEditEnterCode = payload.allowEditEnterCode; logs.push({ type: "update_maestro_allow_enter_code", remark: `${maestro.AllowEditEnterCode} -> ${payload.allowEditEnterCode}`, }); } if (Object.keys(updates).length === 0) { return { changed: false }; } await db.$transaction([ db.maestro.update({ where: { MaestroID: maestroID }, data: updates }), ...logs.map((log) => db.maestro_log.create({ data: { Type: log.type, MaestroID: maestroID, LogDateTime: new Date(), Remark: log.remark, }, }) ), ]); logger.info("maestros/[id]", "updated", { id: maestroID, fields: Object.keys(updates), duration: Date.now() - t0, }); return { changed: true }; } const studentPageSizeOptions = [10, 20, 50] as const; type StudentPageSize = (typeof studentPageSizeOptions)[number]; const studentSearchSchema = z.object({ page: z.coerce.number().int().min(1).catch(1), pageSize: z.coerce .number() .int() .refine((v) => studentPageSizeOptions.includes(v as StudentPageSize)) .catch(20), q: z.string().trim().catch(""), }); export type MaestroStudentsResult = { items: Array<{ playerID: number; name: string; enterCode: string; accountType: number | null; }>; page: number; pageSize: number; totalCount: number; totalPages: number; q: string; }; export async function getMaestroStudents( maestroID: number, rawSearchParams: RawSearchParams = {} ): Promise { const t0 = Date.now(); const maestroExists = await db.maestro.findUnique({ where: { MaestroID: maestroID }, select: { MaestroID: true }, }); if (!maestroExists) return null; const params = studentSearchSchema.parse(searchParamsToRecord(rawSearchParams)); const skip = (params.page - 1) * params.pageSize; const where: Prisma.playerWhereInput = { MaestroID: maestroID, ...(params.q ? { Name: { contains: params.q } } : {}), }; const [totalCount, players] = await Promise.all([ db.player.count({ where }), db.player.findMany({ where, orderBy: { PlayerID: "desc" }, skip, take: params.pageSize, select: { PlayerID: true, Name: true, EnterCode: true, AccountType: true, }, }), ]); const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize)); logger.debug("maestros/[id]/students", "fetched", { id: maestroID, q: params.q, page: params.page, totalCount, duration: Date.now() - t0, }); return { items: players.map((p) => ({ playerID: p.PlayerID, name: p.Name.trim(), enterCode: p.EnterCode.trim(), accountType: p.AccountType, })), page: params.page, pageSize: params.pageSize, totalCount, totalPages, q: params.q, }; } export async function getMaestros( rawSearchParams: RawSearchParams = {} ): Promise { const t0 = Date.now(); const params = parseMaestroListSearchParams(rawSearchParams); const where = buildMaestroWhere(params); const orderBy = buildMaestroOrderBy(params.sort); const skip = (params.page - 1) * params.pageSize; const [totalCount, maestros] = await Promise.all([ db.maestro.count({ where }), db.maestro.findMany({ where, orderBy, skip, take: params.pageSize, select: { MaestroID: true, Name: true, Email: true, AccountType: true, ActivateStatus: true, AvailableActivateDateTime: true, AcceptClausesDateTime: true, PlayerCount: true, }, }), ]); const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize)); logger.info("maestros", "list fetched", { totalCount, page: params.page, duration: Date.now() - t0, }); return { items: maestros.map(toMaestroListItem), page: params.page, pageSize: params.pageSize, totalCount, totalPages, q: params.q, activateStatus: params.activateStatus, accountType: params.accountType, sort: params.sort, }; } export async function getMaestroDetail( maestroID: number ): Promise { const t0 = Date.now(); const maestro = await db.maestro.findUnique({ where: { MaestroID: maestroID }, select: { MaestroID: true, Name: true, Email: true, AccountType: true, ActivateStatus: true, AvailableActivateDateTime: true, AcceptClausesDateTime: true, PlayerCount: true, AllowEditEnterCode: true, MaestroTestID: true, }, }); if (!maestro) { logger.warn("maestros/[id]", "not found", { id: maestroID }); return null; } const [ playersCount, extensionRequestsCount, upgradeRequestsCount, logsCount, players, extensionRequests, upgradeRequests, logs, ] = await Promise.all([ db.player.count({ where: { MaestroID: maestroID } }), db.maestro_extension.count({ where: { MaestroID: maestroID } }), db.maestro_upgrade.count({ where: { MaestroID: maestroID } }), db.maestro_log.count({ where: { MaestroID: maestroID } }), db.player.findMany({ where: { MaestroID: maestroID }, orderBy: { PlayerID: "desc" }, take: 20, select: { PlayerID: true, Name: true, EnterCode: true, AccountType: true, }, }), db.maestro_extension.findMany({ where: { MaestroID: maestroID }, orderBy: { MaestroExtensionID: "desc" }, take: 20, select: { MaestroExtensionID: true, AccountType: true, RequestedDateTime: true, Status: true, }, }), db.maestro_upgrade.findMany({ where: { MaestroID: maestroID }, orderBy: { MaestroUpgradeID: "desc" }, take: 20, select: { MaestroUpgradeID: true, RegisteredActivateStatus: true, RegisteredAccountType: true, RequestedAccountType: true, RequestedDateTime: true, Status: true, }, }), db.maestro_log.findMany({ where: { MaestroID: maestroID }, orderBy: { MaestroLogID: "desc" }, take: 30, select: { MaestroLogID: true, Type: true, LogDateTime: true, Remark: true, }, }), ]); logger.info("maestros/[id]", "fetched", { id: maestroID, duration: Date.now() - t0, }); return { maestro: { ...toMaestroListItem(maestro), allowEditEnterCode: maestro.AllowEditEnterCode, maestroTestID: maestro.MaestroTestID, }, counts: { players: playersCount, extensionRequests: extensionRequestsCount, upgradeRequests: upgradeRequestsCount, logs: logsCount, }, players: players.map((player) => ({ playerID: player.PlayerID, name: player.Name.trim(), enterCode: player.EnterCode.trim(), accountType: player.AccountType, })), extensionRequests: extensionRequests.map((request) => ({ maestroExtensionID: request.MaestroExtensionID, accountType: request.AccountType, requestedDateTime: request.RequestedDateTime.toISOString(), status: request.Status, })), upgradeRequests: upgradeRequests.map((request) => ({ maestroUpgradeID: request.MaestroUpgradeID, registeredActivateStatus: request.RegisteredActivateStatus, registeredAccountType: request.RegisteredAccountType, requestedAccountType: request.RequestedAccountType, requestedDateTime: request.RequestedDateTime.toISOString(), status: request.Status, })), logs: logs.map((log) => ({ maestroLogID: log.MaestroLogID, type: log.Type.trim(), logDateTime: log.LogDateTime.toISOString(), remark: log.Remark.trim(), })), }; } export function parseMaestroListSearchParams( rawSearchParams: RawSearchParams ): MaestroListSearchParams { return maestroListSearchSchema.parse(searchParamsToRecord(rawSearchParams)); } function buildMaestroWhere( params: MaestroListSearchParams ): Prisma.maestroWhereInput { const conditions: Prisma.maestroWhereInput[] = []; if (params.q) { const keywordConditions: Prisma.maestroWhereInput[] = [ { Name: { contains: params.q } }, { Email: { contains: params.q } }, ]; const numericQuery = Number(params.q); if (Number.isInteger(numericQuery) && numericQuery > 0) { keywordConditions.unshift({ MaestroID: numericQuery }); } conditions.push({ OR: keywordConditions }); } if ( typeof params.activateStatus === "number" && Number.isInteger(params.activateStatus) ) { conditions.push({ ActivateStatus: params.activateStatus }); } if ( typeof params.accountType === "number" && Number.isInteger(params.accountType) ) { conditions.push({ AccountType: params.accountType }); } return conditions.length > 0 ? { AND: conditions } : {}; } function buildMaestroOrderBy( sort: MaestroSort ): Prisma.maestroOrderByWithRelationInput { switch (sort) { case "id-asc": return { MaestroID: "asc" }; case "joinedAt-desc": return { AcceptClausesDateTime: "desc" }; case "joinedAt-asc": return { AcceptClausesDateTime: "asc" }; case "availableAt-desc": return { AvailableActivateDateTime: "desc" }; case "availableAt-asc": return { AvailableActivateDateTime: "asc" }; case "id-desc": default: return { MaestroID: "desc" }; } } 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 toMaestroListItem(maestro: { MaestroID: number; Name: string; Email: string; AccountType: number; ActivateStatus: number; AvailableActivateDateTime: Date; AcceptClausesDateTime: Date; PlayerCount: number; }): MaestroListItem { return { maestroID: maestro.MaestroID, name: maestro.Name.trim(), email: maestro.Email.trim(), accountType: maestro.AccountType, activateStatus: maestro.ActivateStatus, availableActivateDateTime: maestro.AvailableActivateDateTime.toISOString(), acceptClausesDateTime: maestro.AcceptClausesDateTime.toISOString(), playerCount: maestro.PlayerCount, }; }