전체 코드, 구조 검토 - P1 코드 수정 진행
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user