368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
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 {
|
|
calculateExtendedAvailableDate,
|
|
getAccountTypeLabel,
|
|
} from "@/lib/utils";
|
|
|
|
const pageSizeOptions = [10, 20, 50, 100] as const;
|
|
|
|
const extensionRequestSearchSchema = 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<string, string | string[] | undefined>;
|
|
export type ExtensionRequestSearchParams = z.infer<
|
|
typeof extensionRequestSearchSchema
|
|
>;
|
|
|
|
export type ExtensionRequestListItem = {
|
|
maestroExtensionID: number;
|
|
maestroID: number;
|
|
maestroName: string;
|
|
requestedAccountType: number;
|
|
currentAccountType: number;
|
|
activateStatus: number;
|
|
requestedDateTime: string;
|
|
availableActivateDateTime: string;
|
|
status: number;
|
|
};
|
|
|
|
export type ExtensionRequestListResult = {
|
|
items: ExtensionRequestListItem[];
|
|
page: number;
|
|
pageSize: number;
|
|
totalCount: number;
|
|
totalPages: number;
|
|
q: string;
|
|
status?: number | "all";
|
|
};
|
|
|
|
export async function getExtensionRequests(
|
|
rawSearchParams: RawSearchParams = {}
|
|
): Promise<ExtensionRequestListResult> {
|
|
const t0 = Date.now();
|
|
const params = parseExtensionRequestSearchParams(rawSearchParams);
|
|
const where = buildExtensionRequestWhere(params);
|
|
const skip = (params.page - 1) * params.pageSize;
|
|
|
|
const [totalCount, requests] = await Promise.all([
|
|
db.maestro_extension.count({ where }),
|
|
db.maestro_extension.findMany({
|
|
where,
|
|
orderBy: { MaestroExtensionID: "desc" },
|
|
skip,
|
|
take: params.pageSize,
|
|
select: {
|
|
MaestroExtensionID: true,
|
|
MaestroID: true,
|
|
AccountType: true,
|
|
RequestedDateTime: true,
|
|
Status: true,
|
|
maestro: {
|
|
select: {
|
|
Name: true,
|
|
AccountType: true,
|
|
ActivateStatus: true,
|
|
AvailableActivateDateTime: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
logger.info("extension-requests", "list fetched", {
|
|
totalCount,
|
|
page: params.page,
|
|
duration: Date.now() - t0,
|
|
});
|
|
|
|
return {
|
|
items: requests.map(toExtensionRequestListItem),
|
|
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 approveExtensionRequest(
|
|
maestroExtensionID: number
|
|
): Promise<{
|
|
availableActivateDateTime: string;
|
|
maestroName: string;
|
|
maestroEmail: string;
|
|
}> {
|
|
return db.$transaction(async (tx) => {
|
|
// Atomically claim the request — prevents concurrent double-approval
|
|
const claimed = await tx.maestro_extension.updateMany({
|
|
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
|
|
data: { Status: REQUEST_STATUSES.APPLIED },
|
|
});
|
|
if (claimed.count === 0) {
|
|
const exists = await tx.maestro_extension.count({
|
|
where: { MaestroExtensionID: maestroExtensionID },
|
|
});
|
|
throw new ApiError(
|
|
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
|
|
exists ? 409 : 404
|
|
);
|
|
}
|
|
|
|
const request = await tx.maestro_extension.findUnique({
|
|
where: { MaestroExtensionID: maestroExtensionID },
|
|
select: {
|
|
MaestroID: true,
|
|
AccountType: true,
|
|
maestro: {
|
|
select: {
|
|
Name: true,
|
|
Email: true,
|
|
AvailableActivateDateTime: true,
|
|
ActivateStatus: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
|
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
|
|
}
|
|
|
|
const availableActivateDateTime = calculateExtendedAvailableDate(
|
|
request!.maestro.AvailableActivateDateTime
|
|
);
|
|
|
|
await tx.maestro.update({
|
|
where: { MaestroID: request!.MaestroID },
|
|
data: {
|
|
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
|
AvailableActivateDateTime: availableActivateDateTime,
|
|
},
|
|
});
|
|
await tx.maestro_extension.updateMany({
|
|
where: {
|
|
MaestroID: request!.MaestroID,
|
|
Status: REQUEST_STATUSES.REQUESTED,
|
|
},
|
|
data: { Status: REQUEST_STATUSES.CLOSED },
|
|
});
|
|
await tx.maestro_log.create({
|
|
data: {
|
|
Type: "extension_maestro",
|
|
MaestroID: request!.MaestroID,
|
|
LogDateTime: new Date(),
|
|
Remark: buildExtensionLogRemark(
|
|
request!.maestro.Name,
|
|
request!.AccountType
|
|
),
|
|
},
|
|
});
|
|
|
|
return {
|
|
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
|
maestroName: request!.maestro.Name.trim(),
|
|
maestroEmail: request!.maestro.Email.trim(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function createExtensionRequest(
|
|
maestroID: number
|
|
): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> {
|
|
return db.$transaction(async (tx) => {
|
|
const maestro = await tx.maestro.findUnique({
|
|
where: { MaestroID: maestroID },
|
|
select: { AccountType: true, ActivateStatus: true, Name: true },
|
|
});
|
|
|
|
if (!maestro) {
|
|
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
|
}
|
|
|
|
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) {
|
|
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400);
|
|
}
|
|
|
|
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
|
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400);
|
|
}
|
|
|
|
const request = await tx.maestro_extension.create({
|
|
data: {
|
|
MaestroID: maestroID,
|
|
AccountType: maestro.AccountType,
|
|
RequestedDateTime: new Date(),
|
|
Status: REQUEST_STATUSES.REQUESTED,
|
|
},
|
|
select: {
|
|
MaestroExtensionID: true,
|
|
RequestedDateTime: true,
|
|
Status: true,
|
|
},
|
|
});
|
|
|
|
await tx.maestro_log.create({
|
|
data: {
|
|
Type: "request_extension_maestro",
|
|
MaestroID: maestroID,
|
|
LogDateTime: new Date(),
|
|
Remark: buildExtensionLogRemark(maestro.Name, maestro.AccountType),
|
|
},
|
|
});
|
|
|
|
return {
|
|
maestroExtensionID: request.MaestroExtensionID,
|
|
requestedDateTime: request.RequestedDateTime.toISOString(),
|
|
status: request.Status,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function cancelExtensionRequest(
|
|
maestroExtensionID: number
|
|
): Promise<void> {
|
|
await db.$transaction(async (tx) => {
|
|
const claimed = await tx.maestro_extension.updateMany({
|
|
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
|
|
data: { Status: REQUEST_STATUSES.CLOSED },
|
|
});
|
|
if (claimed.count === 0) {
|
|
const exists = await tx.maestro_extension.count({
|
|
where: { MaestroExtensionID: maestroExtensionID },
|
|
});
|
|
throw new ApiError(
|
|
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
|
|
exists ? 409 : 404
|
|
);
|
|
}
|
|
|
|
const request = await tx.maestro_extension.findUnique({
|
|
where: { MaestroExtensionID: maestroExtensionID },
|
|
select: {
|
|
MaestroID: true,
|
|
AccountType: true,
|
|
maestro: { select: { Name: true } },
|
|
},
|
|
});
|
|
|
|
await tx.maestro_log.create({
|
|
data: {
|
|
Type: "cancel_extension_maestro",
|
|
MaestroID: request!.MaestroID,
|
|
LogDateTime: new Date(),
|
|
Remark: buildExtensionLogRemark(
|
|
request!.maestro.Name,
|
|
request!.AccountType
|
|
),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export function parseExtensionRequestSearchParams(
|
|
rawSearchParams: RawSearchParams
|
|
): ExtensionRequestSearchParams {
|
|
return extensionRequestSearchSchema.parse(
|
|
searchParamsToRecord(rawSearchParams)
|
|
);
|
|
}
|
|
|
|
|
|
function buildExtensionRequestWhere(
|
|
params: ExtensionRequestSearchParams
|
|
): Prisma.maestro_extensionWhereInput {
|
|
const conditions: Prisma.maestro_extensionWhereInput[] = [];
|
|
|
|
if (params.q) {
|
|
const keywordConditions: Prisma.maestro_extensionWhereInput[] = [
|
|
{ maestro: { Name: { contains: params.q } } },
|
|
];
|
|
const numericQuery = Number(params.q);
|
|
|
|
if (Number.isInteger(numericQuery) && numericQuery > 0) {
|
|
keywordConditions.unshift(
|
|
{ MaestroExtensionID: 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<string, string | undefined> {
|
|
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 toExtensionRequestListItem(request: {
|
|
MaestroExtensionID: number;
|
|
MaestroID: number;
|
|
AccountType: number;
|
|
RequestedDateTime: Date;
|
|
Status: number;
|
|
maestro: {
|
|
Name: string;
|
|
AccountType: number;
|
|
ActivateStatus: number;
|
|
AvailableActivateDateTime: Date;
|
|
};
|
|
}): ExtensionRequestListItem {
|
|
return {
|
|
maestroExtensionID: request.MaestroExtensionID,
|
|
maestroID: request.MaestroID,
|
|
maestroName: request.maestro.Name.trim(),
|
|
requestedAccountType: request.AccountType,
|
|
currentAccountType: request.maestro.AccountType,
|
|
activateStatus: request.maestro.ActivateStatus,
|
|
requestedDateTime: request.RequestedDateTime.toISOString(),
|
|
availableActivateDateTime:
|
|
request.maestro.AvailableActivateDateTime.toISOString(),
|
|
status: request.Status,
|
|
};
|
|
}
|
|
|
|
function buildExtensionLogRemark(maestroName: string, accountType: number) {
|
|
return `${maestroName.trim()}(${getAccountTypeLabel(accountType)})`.slice(
|
|
0,
|
|
100
|
|
);
|
|
}
|