전체 코드, 구조 검토 - P1 코드 수정 진행

This commit is contained in:
2026-07-04 15:32:19 +09:00
parent 3c5848cdc5
commit 41a246ad7b
6 changed files with 110 additions and 41 deletions
+5
View File
@@ -20,6 +20,9 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade. - Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`. - When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB. - **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
- **Upgrade requests**: TRIAL accounts (`ActivateStatus = 1`) may request the **same tier** (trial-to-paid conversion, `requestedAccountType >= AccountType`). ACTIVE accounts must use a strictly higher tier (`>`). At approval time, this direction is re-verified against the maestro's **current** `AccountType` — if it became invalid (e.g., admin manually raised the tier), the approval throws 409 and rolls back.
- Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional.
## Authentication ## Authentication
@@ -62,6 +65,8 @@ Always record a log entry after a successful DB change. Use these `Type` values:
| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) | | `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) |
| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` | | `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) | | `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
`Remark` column is `char(100)` — keep values under 100 characters. `Remark` column is `char(100)` — keep values under 100 characters.
@@ -5,13 +5,14 @@ import { useRouter } from "next/navigation";
import { ArrowUp } from "lucide-react"; import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ACCOUNT_TYPES } from "@/lib/constants"; import { ACCOUNT_TYPES, ACTIVATE_STATUSES } from "@/lib/constants";
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils"; import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
import type { MaestroDetail } from "@/lib/maestros"; import type { MaestroDetail } from "@/lib/maestros";
type UpgradeRequestsSectionProps = { type UpgradeRequestsSectionProps = {
maestroID: number; maestroID: number;
accountType: number; accountType: number;
activateStatus: number;
totalCount: number; totalCount: number;
upgradeRequests: MaestroDetail["upgradeRequests"]; upgradeRequests: MaestroDetail["upgradeRequests"];
}; };
@@ -30,6 +31,7 @@ const selectClass =
export function UpgradeRequestsSection({ export function UpgradeRequestsSection({
maestroID, maestroID,
accountType, accountType,
activateStatus,
totalCount, totalCount,
upgradeRequests, upgradeRequests,
}: UpgradeRequestsSectionProps) { }: UpgradeRequestsSectionProps) {
@@ -39,8 +41,10 @@ export function UpgradeRequestsSection({
const [selectedAccountType, setSelectedAccountType] = useState<number | null>(null); const [selectedAccountType, setSelectedAccountType] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isTrial = activateStatus === ACTIVATE_STATUSES.TRIAL;
const isUpgradeEnabled = const isUpgradeEnabled =
selectedAccountType !== null && selectedAccountType > accountType; selectedAccountType !== null &&
(isTrial ? selectedAccountType >= accountType : selectedAccountType > accountType);
const isDisabled = isCreating || isPending; const isDisabled = isCreating || isPending;
async function handleUpgrade() { async function handleUpgrade() {
@@ -107,7 +111,7 @@ export function UpgradeRequestsSection({
<option value=""> </option> <option value=""> </option>
{upgradeOptions.map((opt) => ( {upgradeOptions.map((opt) => (
<option <option
disabled={opt.value <= accountType} disabled={isTrial ? opt.value < accountType : opt.value <= accountType}
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
> >
@@ -102,6 +102,7 @@ export default async function MaestroDetailPage({
<UpgradeRequestsSection <UpgradeRequestsSection
accountType={maestro.accountType} accountType={maestro.accountType}
activateStatus={maestro.activateStatus}
maestroID={maestroID} maestroID={maestroID}
totalCount={detail.counts.upgradeRequests} totalCount={detail.counts.upgradeRequests}
upgradeRequests={detail.upgradeRequests} upgradeRequests={detail.upgradeRequests}
+8 -4
View File
@@ -160,10 +160,14 @@
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹 - P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드 - P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일 - P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
2. **Step 2 (P1 비즈니스 결정 → 반영)** 2. **Step 2 (P1 비즈니스 결정 → 반영) ✅ 완료 (2026-07-04)**
- 결정 필요 3건: 연장 대상 ActivateStatus 제한(P1-1), 대행 등록 입금 메일(P1-7), - P1-1: 연장 신청·승인 모두 ActivateStatus=2(ACTIVE) 필수 검증 추가
승인 시 remark 기준값(P2-10) - P1-2: TRIAL 계정의 동일 tier 업그레이드(체험→유료 전환) 허용
- 코드 반영: P1-2(TRIAL 동일 tier 허용), P1-3(승인 재검증), P1-4(트랜잭션), P1-5(신청 로그) - P1-3: 업그레이드 승인 시점에 현재 AccountType 기준 방향 재검증
- P1-4: `createExtensionRequest` 트랜잭션 감싸기
- P1-5: `request_extension_maestro` / `request_upgrade_maestro` 로그 추가
- P1-7: 관리자 대행 등록 시 입금 메일 미발송 — 의도 확인, AGENTS.md에 명시
- P2-10: 신청 시점 스냅샷 유지 — 변경 없음
3. **Step 3 (P1-6 이메일 + 검증)** 3. **Step 3 (P1-6 이메일 + 검증)**
- emailSent 응답 + UI 경고, stage에서 연장/업그레이드(체험→유료, 유료→유료) 메일 실측 - emailSent 응답 + UI 경고, stage에서 연장/업그레이드(체험→유료, 유료→유료) 메일 실측
(phase11 잔여 항목 겸) (phase11 잔여 항목 겸)
+51 -31
View File
@@ -139,11 +139,16 @@ export async function approveExtensionRequest(
Name: true, Name: true,
Email: true, Email: true,
AvailableActivateDateTime: true, AvailableActivateDateTime: true,
ActivateStatus: true,
}, },
}, },
}, },
}); });
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
}
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
request!.maestro.AvailableActivateDateTime request!.maestro.AvailableActivateDateTime
); );
@@ -185,38 +190,53 @@ export async function approveExtensionRequest(
export async function createExtensionRequest( export async function createExtensionRequest(
maestroID: number maestroID: number
): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> { ): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> {
const maestro = await db.maestro.findUnique({ return db.$transaction(async (tx) => {
where: { MaestroID: maestroID }, const maestro = await tx.maestro.findUnique({
select: { AccountType: true }, 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,
};
}); });
if (!maestro) {
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
}
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) {
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400);
}
const request = await db.maestro_extension.create({
data: {
MaestroID: maestroID,
AccountType: maestro.AccountType,
RequestedDateTime: new Date(),
Status: REQUEST_STATUSES.REQUESTED,
},
select: {
MaestroExtensionID: true,
RequestedDateTime: true,
Status: true,
},
});
return {
maestroExtensionID: request.MaestroExtensionID,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
};
} }
export async function cancelExtensionRequest( export async function cancelExtensionRequest(
+38 -3
View File
@@ -143,10 +143,22 @@ export async function approveUpgradeRequest(
RequestedAccountType: true, RequestedAccountType: true,
RegisteredActivateStatus: true, RegisteredActivateStatus: true,
RegisteredAccountType: true, RegisteredAccountType: true,
maestro: { select: { Name: true, Email: true } }, maestro: { select: { Name: true, Email: true, AccountType: true, ActivateStatus: true } },
}, },
}); });
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
const isStillValidUpgrade = isApprovalTrial
? request!.RequestedAccountType >= request!.maestro.AccountType
: request!.RequestedAccountType > request!.maestro.AccountType;
if (!isStillValidUpgrade) {
throw new ApiError(
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
409
);
}
const availableActivateDateTime = calculateUpgradeAvailableDate(); const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({ await tx.maestro.update({
@@ -202,8 +214,18 @@ export async function createUpgradeRequest(
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
} }
if (requestedAccountType <= maestro.AccountType) { const isTrial = maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
throw new ApiError("현재 요금제보다 높은 요금제로만 업그레이드 신청이 가능합니다.", 400); const isValidUpgrade = isTrial
? requestedAccountType >= maestro.AccountType
: requestedAccountType > maestro.AccountType;
if (!isValidUpgrade) {
throw new ApiError(
isTrial
? "체험 계정은 동일 요금제 이상으로만 업그레이드 신청이 가능합니다."
: "현재 요금제보다 높은 요금제로만 업그레이드 신청이 가능합니다.",
400
);
} }
const request = await tx.maestro_upgrade.create({ const request = await tx.maestro_upgrade.create({
@@ -223,6 +245,19 @@ export async function createUpgradeRequest(
}, },
}); });
await tx.maestro_log.create({
data: {
Type: "request_upgrade_maestro",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
maestro.ActivateStatus,
maestro.AccountType,
requestedAccountType
),
},
});
return { return {
maestroUpgradeID: request.MaestroUpgradeID, maestroUpgradeID: request.MaestroUpgradeID,
registeredAccountType: request.RegisteredAccountType, registeredAccountType: request.RegisteredAccountType,