Compare commits
6 Commits
854a3e2bfb
...
a0d371837c
| Author | SHA1 | Date | |
|---|---|---|---|
| a0d371837c | |||
| 56f32f8045 | |||
| 0a9aee58e0 | |||
| 2231ac4bfe | |||
| 41a246ad7b | |||
| 3c5848cdc5 |
@@ -11,13 +11,18 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
|
|||||||
## Critical rules
|
## Critical rules
|
||||||
|
|
||||||
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
|
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
|
||||||
- **All mutating APIs must verify current `Status = 1` before acting** (idempotency guard, returns 409 if already processed).
|
- **Race-condition guard for approve/cancel/reject**: use a conditional `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })` as the first step inside a transaction. If `count === 0`, check existence to return 404 vs 409. Never use read-then-write (findUnique → check Status → update) — it allows double-processing under concurrent requests.
|
||||||
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
|
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
|
||||||
- **Send email only after a successful commit**, never inside the transaction.
|
- **Send email only after a successful commit**, never inside the transaction.
|
||||||
|
- **Never use `include: { maestro: true }`** when querying `maestro_extension` or `maestro_upgrade`. Always use `select` with only the needed columns to avoid logging the maestro `Password` field.
|
||||||
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
|
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
|
||||||
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
|
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
|
||||||
- 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.
|
||||||
|
- **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
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
|
|||||||
- Config lives in `auth.ts` (project root), not inside `app/`.
|
- Config lives in `auth.ts` (project root), not inside `app/`.
|
||||||
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
|
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
|
||||||
- API routes must call `auth()` and return 401 if no session.
|
- API routes must call `auth()` and return 401 if no session.
|
||||||
|
- Session `maxAge` is 8 hours — do not lengthen without review.
|
||||||
|
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
|
|
||||||
@@ -60,6 +67,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.
|
||||||
|
|
||||||
@@ -74,6 +83,19 @@ Always record a log entry after a successful DB change. Use these `Type` values:
|
|||||||
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
||||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
||||||
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
|
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
|
||||||
|
- Phase 13 (security/correctness fixes):
|
||||||
|
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
|
||||||
|
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
|
||||||
|
- P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency.
|
||||||
|
- P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade.
|
||||||
|
- P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time.
|
||||||
|
- P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries.
|
||||||
|
- P1-6: Extension/upgrade approval routes now `await` email send and include `emailSent: boolean` in response; table UI shows inline amber warning when `emailSent === false`.
|
||||||
|
- P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed.
|
||||||
|
- P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses).
|
||||||
|
- P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`).
|
||||||
|
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
|
||||||
|
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
|
||||||
|
|
||||||
## Planned phases (not yet implemented)
|
## Planned phases (not yet implemented)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스
|
|||||||
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
|
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
|
||||||
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
|
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
|
||||||
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
||||||
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹
|
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
|
||||||
|
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
|
||||||
|
|
||||||
## 개발 환경 설정
|
## 개발 환경 설정
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@ pnpm dev
|
|||||||
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
|
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
|
||||||
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
|
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
|
||||||
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
|
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
|
||||||
|
| `MAIL_OVERRIDE_TO` | — | 설정 시 모든 메일 수신인을 이 주소로 고정 — local/stage 테스트용 (Phase 11) |
|
||||||
|
|
||||||
## 배포 (Synology NAS Docker)
|
## 배포 (Synology NAS Docker)
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ pnpm dev
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml`의 `environment` 섹션에 위 환경변수를 설정한다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
|
`docker-compose.yml`의 `environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default async function ExtensionRequestsPage({
|
|||||||
size: "default",
|
size: "default",
|
||||||
className: "h-9",
|
className: "h-9",
|
||||||
})}
|
})}
|
||||||
href="/extension-requests"
|
href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function ExtensionRequestsSection({
|
|||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
연장
|
연장
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
|
import type { MaestroLogsResult } from "@/lib/maestros";
|
||||||
|
|
||||||
|
type LogsSectionProps = {
|
||||||
|
maestroID: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogsSection({ maestroID }: LogsSectionProps) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const [data, setData] = useState<MaestroLogsResult | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
pageSize: String(pageSize),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/maestros/${maestroID}/logs?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("처리 로그를 불러오지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as MaestroLogsResult;
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "처리 로그를 불러오지 못했습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [maestroID, page, pageSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border bg-background p-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">처리 로그</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{data ? `전체 ${data.totalCount.toLocaleString()}건` : "로딩 중..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogTable isLoading={isLoading} result={data} />
|
||||||
|
{data ? (
|
||||||
|
<LogPagination
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
page={data.page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogTable({
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
result: MaestroLogsResult | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading || !result) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
|
||||||
|
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||||
|
<thead className="bg-muted/60">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
LogID
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
유형
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
처리일시
|
||||||
|
</th>
|
||||||
|
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
내용
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.length > 0 ? (
|
||||||
|
result.items.map((log) => (
|
||||||
|
<tr
|
||||||
|
className="border-b last:border-b-0"
|
||||||
|
key={log.maestroLogID}
|
||||||
|
>
|
||||||
|
<td className="h-11 px-3 align-middle">
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{log.maestroLogID}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="h-11 px-3 align-middle">{log.type}</td>
|
||||||
|
<td className="h-11 px-3 align-middle">
|
||||||
|
{formatDateTime(new Date(log.logDateTime))}
|
||||||
|
</td>
|
||||||
|
<td className="h-11 max-w-[360px] break-words px-3 align-middle">
|
||||||
|
{log.remark}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
||||||
|
colSpan={4}
|
||||||
|
>
|
||||||
|
처리 로그가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogPagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
}) {
|
||||||
|
const showNav = totalPages > 1;
|
||||||
|
const paginationItems = buildPaginationItems(page, totalPages);
|
||||||
|
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||||
|
const lastVisiblePage = paginationItems.at(-1) ?? totalPages;
|
||||||
|
const hasFirstPage = paginationItems.includes(1);
|
||||||
|
const hasLastPage = paginationItems.includes(totalPages);
|
||||||
|
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||||
|
const hasNextPageGroup = lastVisiblePage < totalPages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
value={pageSize}
|
||||||
|
>
|
||||||
|
{[10, 20, 50].map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||||
|
</label>
|
||||||
|
{showNav ? (
|
||||||
|
<nav
|
||||||
|
aria-label="처리 로그 페이지"
|
||||||
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{!hasFirstPage ? (
|
||||||
|
<Button
|
||||||
|
aria-label="처음 페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronsLeft aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasPreviousPageGroup ? (
|
||||||
|
<Button
|
||||||
|
aria-label="이전 5페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 5))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{paginationItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
aria-current={item === page ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: item === page ? "default" : "outline",
|
||||||
|
size: "sm",
|
||||||
|
}),
|
||||||
|
"min-w-8 px-2"
|
||||||
|
)}
|
||||||
|
key={item}
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPageGroup ? (
|
||||||
|
<Button
|
||||||
|
aria-label="다음 5페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hasLastPage ? (
|
||||||
|
<Button
|
||||||
|
aria-label="끝 페이지"
|
||||||
|
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronsRight aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||||
|
const startPage = Math.max(1, currentPage - 2);
|
||||||
|
const endPage = Math.min(totalPages, currentPage + 2);
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
{ length: endPage - startPage + 1 },
|
||||||
|
(_, index) => startPage + index
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
>
|
>
|
||||||
@@ -120,7 +124,6 @@ export function UpgradeRequestsSection({
|
|||||||
onClick={() => void handleUpgrade()}
|
onClick={() => void handleUpgrade()}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
<ArrowUp aria-hidden="true" className="size-4" />
|
<ArrowUp aria-hidden="true" className="size-4" />
|
||||||
업그레이드
|
업그레이드
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { buttonVariants } from "@/components/ui/button";
|
|||||||
import { getMaestroDetail } from "@/lib/maestros";
|
import { getMaestroDetail } from "@/lib/maestros";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDateTime,
|
|
||||||
getAccountTypeLabel,
|
getAccountTypeLabel,
|
||||||
getActivateStatusLabel,
|
getActivateStatusLabel,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
@@ -14,6 +13,7 @@ import { MaestroEditForm } from "./MaestroEditForm";
|
|||||||
import { StudentsSection } from "./StudentsSection";
|
import { StudentsSection } from "./StudentsSection";
|
||||||
import { ExtensionRequestsSection } from "./ExtensionRequestsSection";
|
import { ExtensionRequestsSection } from "./ExtensionRequestsSection";
|
||||||
import { UpgradeRequestsSection } from "./UpgradeRequestsSection";
|
import { UpgradeRequestsSection } from "./UpgradeRequestsSection";
|
||||||
|
import { LogsSection } from "./LogsSection";
|
||||||
|
|
||||||
type MaestroDetailPageProps = {
|
type MaestroDetailPageProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -102,28 +102,14 @@ 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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="rounded-lg border bg-background p-5">
|
<LogsSection maestroID={maestroID} />
|
||||||
<h2 className="text-base font-semibold">처리 로그</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
전체 {detail.counts.logs.toLocaleString()}건 중 최근 30건
|
|
||||||
</p>
|
|
||||||
<SimpleTable
|
|
||||||
emptyText="처리 로그가 없습니다."
|
|
||||||
headers={["LogID", "유형", "처리일시", "내용"]}
|
|
||||||
rows={detail.logs.map((log) => [
|
|
||||||
log.maestroLogID,
|
|
||||||
log.type,
|
|
||||||
formatDateTime(log.logDateTime),
|
|
||||||
log.remark,
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,59 +122,3 @@ function SummaryCard({ label, value }: { label: string; value: string }) {
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SimpleTable({
|
|
||||||
headers,
|
|
||||||
rows,
|
|
||||||
emptyText,
|
|
||||||
}: {
|
|
||||||
headers: string[];
|
|
||||||
rows: Array<Array<string | number>>;
|
|
||||||
emptyText: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
|
||||||
<thead className="bg-muted/60">
|
|
||||||
<tr className="border-b">
|
|
||||||
{headers.map((header) => (
|
|
||||||
<th
|
|
||||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
|
||||||
key={header}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length > 0 ? (
|
|
||||||
rows.map((row, index) => (
|
|
||||||
<tr className="border-b last:border-b-0" key={index}>
|
|
||||||
{row.map((cell, cellIndex) => (
|
|
||||||
<td
|
|
||||||
className="h-11 max-w-[360px] break-words px-3 align-middle"
|
|
||||||
key={cellIndex}
|
|
||||||
>
|
|
||||||
{cell}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
|
||||||
colSpan={headers.length}
|
|
||||||
>
|
|
||||||
{emptyText}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default async function UpgradeRequestsPage({
|
|||||||
size: "default",
|
size: "default",
|
||||||
className: "h-9",
|
className: "h-9",
|
||||||
})}
|
})}
|
||||||
href="/upgrade-requests"
|
href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ export const PATCH = withApiHandler<{ id: string }>(
|
|||||||
id: maestroExtensionID,
|
id: maestroExtensionID,
|
||||||
duration: Date.now() - t0,
|
duration: Date.now() - t0,
|
||||||
});
|
});
|
||||||
void sendExtensionDoneEmail(
|
const emailSent = await sendExtensionDoneEmail(
|
||||||
result.maestroName,
|
result.maestroName,
|
||||||
result.maestroEmail,
|
result.maestroEmail,
|
||||||
formatDate(result.availableActivateDateTime)
|
formatDate(result.availableActivateDateTime)
|
||||||
);
|
);
|
||||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
|
||||||
}
|
}
|
||||||
|
|
||||||
await cancelExtensionRequest(maestroExtensionID);
|
await cancelExtensionRequest(maestroExtensionID);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getMaestroLogs } from "@/lib/maestros";
|
||||||
|
import { ApiError } from "@/lib/errors";
|
||||||
|
import { withApiHandler } from "@/lib/api-handler";
|
||||||
|
|
||||||
|
const CTX = "maestros/[id]/logs";
|
||||||
|
|
||||||
|
export const GET = withApiHandler<{ id: string }>(
|
||||||
|
CTX,
|
||||||
|
async (request, { params }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const maestroID = Number(id);
|
||||||
|
|
||||||
|
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||||
|
throw new ApiError("Invalid maestro id", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const result = await getMaestroLogs(maestroID, url.searchParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new ApiError("Not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -40,7 +40,7 @@ export const PATCH = withApiHandler<{ id: string }>(
|
|||||||
id: maestroUpgradeID,
|
id: maestroUpgradeID,
|
||||||
duration: Date.now() - t0,
|
duration: Date.now() - t0,
|
||||||
});
|
});
|
||||||
void sendUpgradeDoneEmail(
|
const emailSent = await sendUpgradeDoneEmail(
|
||||||
result.maestroName,
|
result.maestroName,
|
||||||
result.maestroEmail,
|
result.maestroEmail,
|
||||||
result.registeredActivateStatus,
|
result.registeredActivateStatus,
|
||||||
@@ -48,7 +48,7 @@ export const PATCH = withApiHandler<{ id: string }>(
|
|||||||
result.requestedAccountType,
|
result.requestedAccountType,
|
||||||
formatDate(result.availableActivateDateTime)
|
formatDate(result.availableActivateDateTime)
|
||||||
);
|
);
|
||||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
|
||||||
}
|
}
|
||||||
|
|
||||||
await rejectUpgradeRequest(maestroUpgradeID);
|
await rejectUpgradeRequest(maestroUpgradeID);
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import { logger } from "@/lib/logger";
|
|||||||
|
|
||||||
const AUTH_CTX = "auth";
|
const AUTH_CTX = "auth";
|
||||||
|
|
||||||
|
type AttemptRecord = { count: number; until: number };
|
||||||
|
const loginAttempts = new Map<string, AttemptRecord>();
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function isLockedOut(adminName: string): boolean {
|
||||||
|
const record = loginAttempts.get(adminName);
|
||||||
|
if (!record || record.count < MAX_ATTEMPTS) return false;
|
||||||
|
if (record.until > Date.now()) return true;
|
||||||
|
loginAttempts.delete(adminName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(adminName: string): void {
|
||||||
|
const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 };
|
||||||
|
record.count += 1;
|
||||||
|
if (record.count >= MAX_ATTEMPTS) {
|
||||||
|
record.until = Date.now() + LOCKOUT_MS;
|
||||||
|
}
|
||||||
|
loginAttempts.set(adminName, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAttempts(adminName: string): void {
|
||||||
|
loginAttempts.delete(adminName);
|
||||||
|
}
|
||||||
|
|
||||||
const credentialsSchema = z.object({
|
const credentialsSchema = z.object({
|
||||||
adminName: z.string().trim().min(1),
|
adminName: z.string().trim().min(1),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
@@ -53,6 +79,7 @@ export const {
|
|||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
|
maxAge: 8 * 60 * 60,
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
Credentials({
|
||||||
@@ -67,8 +94,14 @@ export const {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { db } = await import("@/lib/db");
|
|
||||||
const { adminName, password } = parsedCredentials.data;
|
const { adminName, password } = parsedCredentials.data;
|
||||||
|
|
||||||
|
if (isLockedOut(adminName)) {
|
||||||
|
logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
const admins = await db.$queryRaw<AdminAuthRow[]>`
|
const admins = await db.$queryRaw<AdminAuthRow[]>`
|
||||||
SELECT AdminID, Name, Email, AccountType, ActivateStatus
|
SELECT AdminID, Name, Email, AccountType, ActivateStatus
|
||||||
FROM admin
|
FROM admin
|
||||||
@@ -81,10 +114,12 @@ export const {
|
|||||||
const activateStatus = Number(admin?.ActivateStatus);
|
const activateStatus = Number(admin?.ActivateStatus);
|
||||||
|
|
||||||
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
|
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
|
||||||
|
recordFailure(adminName);
|
||||||
logger.warn(AUTH_CTX, "login failed", { adminName });
|
logger.warn(AUTH_CTX, "login failed", { adminName });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAttempts(adminName);
|
||||||
logger.info(AUTH_CTX, "login success", { adminName, adminID });
|
logger.info(AUTH_CTX, "login success", { adminName, adminID });
|
||||||
return {
|
return {
|
||||||
id: String(adminID),
|
id: String(adminID),
|
||||||
|
|||||||
@@ -108,19 +108,16 @@ function ExtensionRequestActions({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [emailWarning, setEmailWarning] = useState(false);
|
||||||
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
||||||
|
|
||||||
if (!isRequested) {
|
if (!isRequested && !emailWarning) {
|
||||||
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAction(action: "approve" | "cancel") {
|
async function submitAction(action: "approve" | "cancel") {
|
||||||
const actionLabel = action === "approve" ? "승인" : "취소";
|
const actionLabel = action === "approve" ? "승인" : "취소";
|
||||||
const confirmed = window.confirm(
|
if (!window.confirm(`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`)) {
|
||||||
`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,14 +133,19 @@ function ExtensionRequestActions({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const body = (await response.json().catch(() => null)) as {
|
||||||
const body = (await response.json().catch(() => null)) as {
|
message?: string;
|
||||||
message?: string;
|
emailSent?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
|
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "approve" && body?.emailSent === false) {
|
||||||
|
setEmailWarning(true);
|
||||||
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
@@ -158,27 +160,36 @@ function ExtensionRequestActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
{isRequested ? (
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
disabled={isPending}
|
<Button
|
||||||
onClick={() => void submitAction("approve")}
|
disabled={isPending}
|
||||||
size="sm"
|
onClick={() => void submitAction("approve")}
|
||||||
type="button"
|
size="sm"
|
||||||
>
|
type="button"
|
||||||
<Check className="size-4" aria-hidden="true" />
|
>
|
||||||
승인
|
<Check className="size-4" aria-hidden="true" />
|
||||||
</Button>
|
승인
|
||||||
<Button
|
</Button>
|
||||||
disabled={isPending}
|
<Button
|
||||||
onClick={() => void submitAction("cancel")}
|
disabled={isPending}
|
||||||
size="sm"
|
onClick={() => void submitAction("cancel")}
|
||||||
type="button"
|
size="sm"
|
||||||
variant="outline"
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
<X className="size-4" aria-hidden="true" />
|
>
|
||||||
취소
|
<X className="size-4" aria-hidden="true" />
|
||||||
</Button>
|
취소
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">처리 완료</span>
|
||||||
|
)}
|
||||||
|
{emailWarning ? (
|
||||||
|
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
이메일 발송 실패 — 수동 확인 필요
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -113,19 +113,16 @@ function UpgradeRequestActions({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [emailWarning, setEmailWarning] = useState(false);
|
||||||
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
||||||
|
|
||||||
if (!isRequested) {
|
if (!isRequested && !emailWarning) {
|
||||||
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAction(action: "approve" | "reject") {
|
async function submitAction(action: "approve" | "reject") {
|
||||||
const actionLabel = action === "approve" ? "승인" : "거절";
|
const actionLabel = action === "approve" ? "승인" : "거절";
|
||||||
const confirmed = window.confirm(
|
if (!window.confirm(`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`)) {
|
||||||
`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,14 +138,17 @@ function UpgradeRequestActions({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
const body = (await response.json().catch(() => null)) as {
|
||||||
const body = (await response.json().catch(() => null)) as {
|
message?: string;
|
||||||
message?: string;
|
emailSent?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
throw new Error(
|
if (!response.ok) {
|
||||||
body?.message ?? "업그레이드 신청 처리에 실패했습니다."
|
throw new Error(body?.message ?? "업그레이드 신청 처리에 실패했습니다.");
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (action === "approve" && body?.emailSent === false) {
|
||||||
|
setEmailWarning(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -165,27 +165,36 @@ function UpgradeRequestActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
{isRequested ? (
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
disabled={isPending}
|
<Button
|
||||||
onClick={() => void submitAction("approve")}
|
disabled={isPending}
|
||||||
size="sm"
|
onClick={() => void submitAction("approve")}
|
||||||
type="button"
|
size="sm"
|
||||||
>
|
type="button"
|
||||||
<Check className="size-4" aria-hidden="true" />
|
>
|
||||||
승인
|
<Check className="size-4" aria-hidden="true" />
|
||||||
</Button>
|
승인
|
||||||
<Button
|
</Button>
|
||||||
disabled={isPending}
|
<Button
|
||||||
onClick={() => void submitAction("reject")}
|
disabled={isPending}
|
||||||
size="sm"
|
onClick={() => void submitAction("reject")}
|
||||||
type="button"
|
size="sm"
|
||||||
variant="outline"
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
<X className="size-4" aria-hidden="true" />
|
>
|
||||||
거절
|
<X className="size-4" aria-hidden="true" />
|
||||||
</Button>
|
거절
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">처리 완료</span>
|
||||||
|
)}
|
||||||
|
{emailWarning ? (
|
||||||
|
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
이메일 발송 실패 — 수동 확인 필요
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border-border bg-background text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
APP_ENV: stage
|
APP_ENV: stage
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
|
TZ: Asia/Seoul
|
||||||
networks:
|
networks:
|
||||||
- proxy-network
|
- proxy-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
- ${ENV_FILE:-.env.production}
|
- ${ENV_FILE:-.env.production}
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
APP_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
|
TZ: Asia/Seoul
|
||||||
networks:
|
networks:
|
||||||
- proxy-network
|
- proxy-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# chocoadmin 전체 검토 및 개선 계획
|
||||||
|
|
||||||
|
작성일: 2026-07-04
|
||||||
|
검토 범위: Phase 0–12 전체 구현 (인증, 마에스트로 목록/상세/수정, 연장/업그레이드 신청·승인, 이메일 발송, 로깅)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 요약
|
||||||
|
|
||||||
|
코드·로직·비즈니스 측면을 전수 검토한 결과, 데이터 파괴적인 치명 결함은 없으나
|
||||||
|
**(1) 로그를 통한 마에스트로 비밀번호 노출 가능성, (2) 동시 승인 이중 처리 가능성,
|
||||||
|
(3) 타임존 미확정으로 인한 만료일 오차** 세 가지가 우선 수정 대상이다.
|
||||||
|
그 외 체험(TRIAL) 계정 관련 비즈니스 규칙 공백과 트랜잭션/로그 기록 비일관성이 있다.
|
||||||
|
|
||||||
|
| 등급 | 건수 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 (즉시 수정) | 3 | 비밀번호 로그 노출, 동시 승인 레이스, 타임존 정책 |
|
||||||
|
| P1 (다음 작업) | 7 | TRIAL 규칙 공백, 승인 시점 재검증, 트랜잭션/로그 일관성, 이메일 실패 처리 |
|
||||||
|
| P2 (보완) | 8 | 매직 넘버, 에러 응답 일관성, 세션 정책, 목록 기본 필터 등 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P0 — 즉시 수정
|
||||||
|
|
||||||
|
### P0-1. 연장 승인 시 maestro 전체 컬럼 조회 → Password 로그 노출 위험
|
||||||
|
|
||||||
|
- 위치: `lib/extension-requests.ts:235` (`getActionTarget`의 `include: { maestro: true }`)
|
||||||
|
- 문제: `maestro.Password`(char 50)까지 조회된다. `lib/db.ts`의 debug 레벨 결과 테이블 로깅이
|
||||||
|
조회 행 전체를 콘솔에 출력하므로, local/stage(debug 기본)에서 승인 처리 시
|
||||||
|
**마에스트로 비밀번호가 로그에 그대로 남는다.** phase9 마스킹 정책 위반.
|
||||||
|
- 대조: `lib/upgrade-requests.ts:253`은 이미 `select: { Name, Email }`로 제한하고 있어 비일관.
|
||||||
|
- 개선:
|
||||||
|
1. extension 쪽 `getActionTarget`을 upgrade와 동일하게 `select` 방식으로 변경
|
||||||
|
(필요 컬럼: 요청 전체 + `maestro.Name`, `Email`, `AvailableActivateDateTime`).
|
||||||
|
2. 방어선 추가: `lib/db.ts` 결과 로깅에서 `Password` 컬럼명을 만나면 `***` 마스킹.
|
||||||
|
|
||||||
|
### P0-2. 동시 승인 레이스 컨디션 (이중 승인 → +2년 연장)
|
||||||
|
|
||||||
|
- 위치: `lib/extension-requests.ts:116-157`, `lib/upgrade-requests.ts:123-168`
|
||||||
|
- 문제: 트랜잭션 안에서 `findUnique`로 `Status = 1` 확인 후 `update`하는 read-then-write 패턴.
|
||||||
|
MariaDB 기본 격리수준(REPEATABLE READ)에서는 두 관리자가 동시에 승인 버튼을 누르면
|
||||||
|
둘 다 `Status = 1`을 읽고 둘 다 진행 → 연장이 두 번 적용(만료일 +2년), 로그·메일 2회 발생 가능.
|
||||||
|
- 개선(택1):
|
||||||
|
- A안(권장): 상태 전이를 조건부 UPDATE로 원자화.
|
||||||
|
`updateMany({ where: { MaestroExtensionID, Status: 1 }, data: { Status: 3 } })`의
|
||||||
|
`count === 0`이면 409 throw. maestro 갱신은 그 뒤에 수행.
|
||||||
|
- B안: `$queryRaw`의 `SELECT ... FOR UPDATE`로 요청 행 잠금 후 진행.
|
||||||
|
- 적용 대상: 연장 승인/취소, 업그레이드 승인/거절 4개 함수 모두.
|
||||||
|
|
||||||
|
### P0-3. 타임존 정책 미확정 — 만료일·이메일 날짜 오차
|
||||||
|
|
||||||
|
- 위치:
|
||||||
|
- `lib/utils.ts:93-105` `calculateExtendedAvailableDate`의 `setHours(0,0,0,0)` — 서버 로컬 자정
|
||||||
|
- `lib/utils.ts:63-75` `formatDate` — 서버 로컬 기준
|
||||||
|
- `lib/upgrade-requests.ts:360-366` `calculateUpgradeAvailableDate`
|
||||||
|
- `docker-compose.yml` / `docker-compose.stage.yml` — `TZ` 환경변수 없음 → 컨테이너는 UTC
|
||||||
|
- 문제: 기존 chocomae(PHP)는 DB 서버 `NOW()`(KST 추정) 기준으로 날짜를 기록해 왔다.
|
||||||
|
chocoadmin 컨테이너가 UTC로 돌면:
|
||||||
|
- 연장 승인 시 "자정"이 UTC 자정(KST 09:00)으로 저장돼 기존 데이터와 9시간 어긋남.
|
||||||
|
- `formatDate`로 만든 이메일 표기 날짜가 KST 기준과 하루 차이 날 수 있음 (KST 00:00–08:59 구간).
|
||||||
|
- `updateMaestro`의 날짜 변경 로그 remark는 `toISOString().slice(0,10)`(UTC)인데
|
||||||
|
다른 곳은 로컬 기준 — 같은 값이 화면/로그/메일에서 다르게 보일 수 있음 (`lib/maestros.ts:216-220`).
|
||||||
|
- 개선:
|
||||||
|
1. docker-compose 두 파일에 `TZ: Asia/Seoul` 명시 (앱 레벨 최소 변경으로 기존 동작 재현).
|
||||||
|
2. 날짜→문자열 변환을 한 가지 방식으로 통일: remark의 `toISOString().slice(0,10)`을
|
||||||
|
`formatDate()`로 교체.
|
||||||
|
3. stage에서 승인 → DB 저장값 → 이메일 표기 날짜를 실측 검증 (phase11 잔여 항목과 함께).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P1 — 비즈니스 규칙·정합성
|
||||||
|
|
||||||
|
### P1-1. 체험(TRIAL) 상태 계정의 연장 신청 허용 문제
|
||||||
|
|
||||||
|
- 위치: `lib/extension-requests.ts:160-195` `createExtensionRequest`
|
||||||
|
- 문제: 유료 `AccountType`(1~5)만 검사하고 `ActivateStatus`는 검사하지 않는다.
|
||||||
|
체험 계정(ActivateStatus=1)도 AccountType 1~5를 가지므로(`TRIAL_ACCOUNT_TYPE_LABELS` 존재)
|
||||||
|
체험 계정에 연장 신청→승인하면 **결제 없이 ActivateStatus=2(활성) + 1년**이 부여된다.
|
||||||
|
승인 로직(`approveExtensionRequest`)에도 동일 검증이 없어 CANCELED(100) 계정도 재활성화된다.
|
||||||
|
- 결정 필요(비즈니스): 연장 대상을 `ActivateStatus = 2(활성)`로 제한할지,
|
||||||
|
만료/취소 계정 연장을 관리자 재량으로 허용할지.
|
||||||
|
- 활성 상태에서만 승인하도록 제한: 신청 생성과 승인 양쪽에서 `ActivateStatus` 검증 추가, 위반 시 400/409.
|
||||||
|
|
||||||
|
### P1-2. 체험 → 동일 요금제 유료 전환 신청 불가
|
||||||
|
|
||||||
|
- 위치: `lib/upgrade-requests.ts:184-186`, `UpgradeRequestsSection.tsx:42-43,110`
|
||||||
|
- 문제: `requestedAccountType <= maestro.AccountType`이면 거부한다.
|
||||||
|
그러나 체험 계정(ActivateStatus=1, AccountType=3)이 같은 3번 요금제를 유료 전환하는 것은
|
||||||
|
정당한 흐름이며, 가격 계산(`calculateAdditionalPrice`)과 완료 메일도 이미 이 케이스를 지원한다.
|
||||||
|
현재는 관리자 대행 등록이 불가능하다(같은 tier가 select에서 disabled + 서버 400).
|
||||||
|
- 개선: `ActivateStatus === TRIAL`이면 `requestedAccountType >= AccountType`(동일 tier 포함) 허용,
|
||||||
|
ACTIVE면 기존대로 `>`만 허용. UI select disabled 조건도 동일하게 분기.
|
||||||
|
|
||||||
|
### P1-3. 업그레이드 승인 시점의 재검증 없음
|
||||||
|
|
||||||
|
- 위치: `lib/upgrade-requests.ts:113-168` `approveUpgradeRequest`
|
||||||
|
- 문제: 신청 생성 시엔 상향 여부를 검증하지만 승인 시엔 하지 않는다.
|
||||||
|
신청 후 관리자가 정보 수정(Phase 10)으로 `AccountType`을 올려두면,
|
||||||
|
대기 중인 신청을 승인할 때 **사실상 다운그레이드**가 적용될 수 있다.
|
||||||
|
- 개선: 승인 트랜잭션 안에서 maestro의 현재 `AccountType`을 읽어
|
||||||
|
`RequestedAccountType`이 여전히 상향(또는 TRIAL 전환)인지 확인, 아니면 409 + 안내 메시지.
|
||||||
|
|
||||||
|
### P1-4. 연장 신청 등록이 트랜잭션 미사용 (일관성 위반)
|
||||||
|
|
||||||
|
- 위치: `lib/extension-requests.ts:160-195`
|
||||||
|
- 문제: maestro 조회 → insert가 트랜잭션 밖에서 수행된다. AGENTS 규칙
|
||||||
|
("모든 변경은 트랜잭션") 위반이며, `createUpgradeRequest`(트랜잭션 사용)와 비일관.
|
||||||
|
- 개선: `db.$transaction`으로 감싸 upgrade 쪽과 동일 구조로 정리.
|
||||||
|
|
||||||
|
### P1-5. 신청 등록 시 maestro_log 미기록
|
||||||
|
|
||||||
|
- 위치: `createExtensionRequest`, `createUpgradeRequest`
|
||||||
|
- 문제: 관리자 대행 신청 등록은 DB 변경인데 로그를 남기지 않는다.
|
||||||
|
("성공한 DB 변경 후 항상 로그 기록" 규칙 위반.) 나중에 신청 출처(마에스트로 본인 vs 관리자 대행)를
|
||||||
|
구분할 수 없다.
|
||||||
|
- 개선: 신규 타입 `request_extension_maestro` / `request_upgrade_maestro`(가칭)를 정의해
|
||||||
|
트랜잭션 안에서 기록. 타입 확정 후 AGENTS.md 로그 표에 추가.
|
||||||
|
|
||||||
|
### P1-6. 이메일 발송 실패가 관리자에게 보이지 않음
|
||||||
|
|
||||||
|
- 위치: `app/api/extension-requests/[id]/route.ts:43-47`, `app/api/upgrade-requests/[id]/route.ts:43-50`
|
||||||
|
- 문제: `void sendXxxDoneEmail(...)` fire-and-forget. 실패해도 로그만 남고
|
||||||
|
승인 응답은 `ok: true` → 관리자는 발송 성공으로 인지. 재발송 수단도 없다.
|
||||||
|
- 개선(단계적):
|
||||||
|
1. `await`로 결과를 받아 응답에 `emailSent: boolean` 포함, 실패 시 목록 UI에 경고 표시.
|
||||||
|
(커밋 후 발송이므로 트랜잭션 규칙과 충돌 없음. 지연은 SMTP 1회 호출 수준.)
|
||||||
|
2. (후속) 실패 건 재발송 버튼 또는 관리자 알림.
|
||||||
|
- 참고: 현재 Docker 상주 프로세스라 void 실행이 잘리지는 않지만, 배포 방식이 바뀌면
|
||||||
|
응답 후 프로세스 중단으로 발송 누락 가능 — await 전환으로 함께 해소된다.
|
||||||
|
|
||||||
|
### P1-7. 관리자 대행 신청 등록 시 입금 안내 메일 부재 (정책 확인)
|
||||||
|
|
||||||
|
- 문제: 기존 서비스는 마에스트로가 신청하면 입금 안내 메일을 발송한다.
|
||||||
|
관리자 대행 등록(Phase 12)은 메일을 보내지 않는데, 의도된 생략인지 확인이 안 됨.
|
||||||
|
- 확인: 기존 서비스와 달리 관리자가 대행해서 등록했을 경우 메일을 보내지 않는 것이 의도가 맞다.
|
||||||
|
- 개선: 정책 결정 후 문서화(AGENTS.md). 발송하기로 하면 기존 안내 메일 템플릿 이식.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P2 — 코드 품질·보완
|
||||||
|
|
||||||
|
| # | 항목 | 위치 | 내용 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P2-1 | 매직 넘버 | `lib/extension-requests.ts:125` | `ActivateStatus: 2` → `ACTIVATE_STATUSES.ACTIVE` 상수 사용 |
|
||||||
|
| P2-2 | 날짜 입력 silent 무시 | `lib/maestros.ts:207-227` | `availableActivateDateTime`이 파싱 불가면 조용히 건너뜀 → 400 반환이 명확 |
|
||||||
|
| P2-3 | datetime-local 파싱 TZ | `lib/maestros.ts:208` | `new Date("YYYY-MM-DDTHH:mm")`은 서버 TZ로 해석. 관리자 브라우저 TZ ≠ 서버 TZ면 저장값 이동. P0-3에서 TZ 고정 후 재확인 |
|
||||||
|
| P2-4 | 이름 중복 검사 TOCTOU | `lib/maestros.ts:153-161` | 검사와 update 사이 레이스. unique 제약이 없으므로(스키마 변경 금지) 검사를 트랜잭션 안으로 이동하는 정도로 완화. 이메일 중복 검사는 아예 없음 — 기존 서비스 정책 확인 필요 |
|
||||||
|
| P2-5 | 미확인 예외 rethrow | `lib/api-handler.ts:47-51` | 예상 외 오류가 Next 기본 500(HTML)으로 반환. JSON `{ message }` 500 응답으로 통일 |
|
||||||
|
| P2-6 | 세션 만료 기본 30일 | `auth.ts` | 관리자 도구 치고 길다. `session.maxAge` 단축(예: 8~24h) 검토 |
|
||||||
|
| P2-7 | 로그인 브루트포스 방어 없음 | `auth.ts:63-96` | rate limit 없음. 공개망 노출 시 실패 횟수 제한/지연 도입 검토. `PASSWORD()` 해시 마이그레이션은 기존 계획대로 별도 과제 |
|
||||||
|
| P2-8 | 목록 기본 필터 | `lib/extension-requests.ts:23-27` 등 | `status` 파라미터가 없으면 `optional`이 catch보다 먼저 적용돼 전체 표시. 기존 관리자 기본은 미처리(Status<2)였음 — 기본값을 `REQUESTED`로 할지 결정 (`.default(REQUEST_STATUSES.REQUESTED)`) |
|
||||||
|
| P2-9 | SQL 파라미터 인라인 로깅 | `lib/db.ts:15-29` | debug 한정이지만 이름/이메일 등 PII가 로그에 남음. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 |
|
||||||
|
| P2-10 | 연장 승인 시 요청 AccountType 불일치 | `lib/extension-requests.ts:140-150` | 신청 시점 AccountType으로 로그/메일 remark 생성 — 신청 후 요금제가 바뀌면 현재값과 다름. 승인 시 maestro 현재값 기준으로 표기할지 결정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 이행 순서 제안
|
||||||
|
|
||||||
|
1. **Step 1 (P0, 코드 수정 소규모) ✅ 완료 (2026-07-04)**
|
||||||
|
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
|
||||||
|
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
|
||||||
|
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
|
||||||
|
2. **Step 2 (P1 비즈니스 결정 → 반영) ✅ 완료 (2026-07-04)**
|
||||||
|
- P1-1: 연장 신청·승인 모두 ActivateStatus=2(ACTIVE) 필수 검증 추가
|
||||||
|
- P1-2: TRIAL 계정의 동일 tier 업그레이드(체험→유료 전환) 허용
|
||||||
|
- 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 이메일 + 검증) ✅ 완료 (2026-07-04)**
|
||||||
|
- API route 2곳: `void sendXxxDoneEmail` → `await emailSent`, 응답에 `emailSent: boolean` 포함
|
||||||
|
- 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨)
|
||||||
|
- stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김
|
||||||
|
4. **Step 4 (P2 일괄) ✅ 완료 (2026-07-04)**
|
||||||
|
- P2-2: `updateMaestro`에서 파싱 불가 날짜 silent skip → 400 반환으로 교체 (`lib/maestros.ts`)
|
||||||
|
- P2-5: `withApiHandler` catch 블록 `throw error` → JSON 500 응답으로 교체 (`lib/api-handler.ts`)
|
||||||
|
- P2-6: NextAuth `session.maxAge: 8 * 60 * 60` 추가 (`auth.ts`)
|
||||||
|
- P2-7: 인메모리 브루트포스 방어 추가 (`auth.ts`) — 5회 실패 시 15분 잠금, 성공 시 카운터 초기화
|
||||||
|
- P2-8: 연장·업그레이드 신청 목록 `status` 기본값 `undefined`(전체) → `REQUEST_STATUSES.REQUESTED` 변경; 결과 타입 `status?: number | "all"` → `status: number | "all"` 업데이트; 초기화 링크 `?status=1` 포함으로 변경
|
||||||
|
- P2-4: 이메일 중복 검사 추가 안 함 (기존 서비스 정책 유지)
|
||||||
|
- P2-9: SQL 파라미터 PII 로깅 정책 — 이름·이메일 등이 debug 레벨에서 노출됨. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 (별도 작업)
|
||||||
|
- P2-10: 변경 없음 (신청 시점 스냅샷 유지)
|
||||||
|
- AGENTS.md 인증 섹션 및 구현 phase 목록 갱신
|
||||||
|
|
||||||
|
각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검토했으나 문제 없음으로 확인한 사항
|
||||||
|
|
||||||
|
- 승인/취소/거절의 트랜잭션 구성과 `Status=1` 사전 확인(단일 요청 기준)은 규칙대로 구현됨.
|
||||||
|
- 업그레이드 승인이 `PlayerCount`를 변경하지 않음 — 규칙 준수.
|
||||||
|
- 메일 발송은 커밋 이후 수행 — 규칙 준수.
|
||||||
|
- 모든 API 라우트가 `withApiHandler`로 `auth()` 검증 — 인증 우회 경로 없음
|
||||||
|
(`proxy.ts` matcher가 `/api/`를 제외하지만 핸들러 레벨에서 401 처리됨).
|
||||||
|
- 로그인 쿼리는 태그드 템플릿 파라미터 바인딩 사용 — SQL 인젝션 없음.
|
||||||
|
`PASSWORD(` 포함 쿼리는 SQL 로깅에서 파라미터 인라인을 생략함.
|
||||||
|
- 검색 파라미터는 zod `.catch()`로 방어되어 잘못된 입력이 500을 유발하지 않음.
|
||||||
|
- NextAuth 쿠키 SameSite=Lax + same-origin fetch 구조로 CSRF 위험은 낮음.
|
||||||
+4
-1
@@ -48,7 +48,10 @@ export function withApiHandler<P = Record<string, never>>(
|
|||||||
error: String(error),
|
error: String(error),
|
||||||
duration: Date.now() - t0,
|
duration: Date.now() - t0,
|
||||||
});
|
});
|
||||||
throw error;
|
return NextResponse.json(
|
||||||
|
{ message: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ function onQuery(e: Prisma.QueryEvent): void {
|
|||||||
|
|
||||||
const COL_MAX_WIDTH = 40;
|
const COL_MAX_WIDTH = 40;
|
||||||
|
|
||||||
|
const SENSITIVE_KEYS = new Set(["Password", "password"]);
|
||||||
|
|
||||||
|
function safeValue(key: string, value: unknown): string {
|
||||||
|
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
function formatValue(v: unknown): string {
|
function formatValue(v: unknown): string {
|
||||||
if (v === null || v === undefined) return "NULL";
|
if (v === null || v === undefined) return "NULL";
|
||||||
if (typeof v === "bigint") return v.toString();
|
if (typeof v === "bigint") return v.toString();
|
||||||
@@ -165,7 +171,7 @@ function buildResultLog(
|
|||||||
if (typeof preview[0] === "object" && preview[0] !== null) {
|
if (typeof preview[0] === "object" && preview[0] !== null) {
|
||||||
const objRows = preview as Record<string, unknown>[];
|
const objRows = preview as Record<string, unknown>[];
|
||||||
const headers = Object.keys(objRows[0]);
|
const headers = Object.keys(objRows[0]);
|
||||||
const rows = objRows.map((row) => headers.map((h) => formatValue(row[h])));
|
const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
|
||||||
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +182,7 @@ function buildResultLog(
|
|||||||
if (result !== null && typeof result === "object") {
|
if (result !== null && typeof result === "object") {
|
||||||
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
||||||
k,
|
k,
|
||||||
formatValue(v),
|
safeValue(k, v),
|
||||||
]);
|
]);
|
||||||
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-74
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { ApiError } from "@/lib/errors";
|
import { ApiError } from "@/lib/errors";
|
||||||
import { PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
|
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
|
||||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||||
import {
|
import {
|
||||||
calculateExtendedAvailableDate,
|
calculateExtendedAvailableDate,
|
||||||
@@ -22,7 +22,7 @@ const extensionRequestSearchSchema = z.object({
|
|||||||
q: z.string().trim().catch(""),
|
q: z.string().trim().catch(""),
|
||||||
status: z
|
status: z
|
||||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||||
.optional()
|
.default(REQUEST_STATUSES.REQUESTED)
|
||||||
.catch(REQUEST_STATUSES.REQUESTED),
|
.catch(REQUEST_STATUSES.REQUESTED),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export type ExtensionRequestListResult = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
q: string;
|
q: string;
|
||||||
status?: number | "all";
|
status: number | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getExtensionRequests(
|
export async function getExtensionRequests(
|
||||||
@@ -114,45 +114,75 @@ export async function approveExtensionRequest(
|
|||||||
maestroEmail: string;
|
maestroEmail: string;
|
||||||
}> {
|
}> {
|
||||||
return db.$transaction(async (tx) => {
|
return db.$transaction(async (tx) => {
|
||||||
const request = await getActionTarget(tx, maestroExtensionID);
|
// 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(
|
const availableActivateDateTime = calculateExtendedAvailableDate(
|
||||||
request.maestro.AvailableActivateDateTime
|
request!.maestro.AvailableActivateDateTime
|
||||||
);
|
);
|
||||||
|
|
||||||
await tx.maestro.update({
|
await tx.maestro.update({
|
||||||
where: { MaestroID: request.MaestroID },
|
where: { MaestroID: request!.MaestroID },
|
||||||
data: {
|
data: {
|
||||||
ActivateStatus: 2,
|
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
||||||
AvailableActivateDateTime: availableActivateDateTime,
|
AvailableActivateDateTime: availableActivateDateTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.maestro_extension.updateMany({
|
await tx.maestro_extension.updateMany({
|
||||||
where: {
|
where: {
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
Status: REQUEST_STATUSES.REQUESTED,
|
Status: REQUEST_STATUSES.REQUESTED,
|
||||||
},
|
},
|
||||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||||
});
|
});
|
||||||
await tx.maestro_extension.update({
|
|
||||||
where: { MaestroExtensionID: request.MaestroExtensionID },
|
|
||||||
data: { Status: REQUEST_STATUSES.APPLIED },
|
|
||||||
});
|
|
||||||
await tx.maestro_log.create({
|
await tx.maestro_log.create({
|
||||||
data: {
|
data: {
|
||||||
Type: "extension_maestro",
|
Type: "extension_maestro",
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
LogDateTime: new Date(),
|
LogDateTime: new Date(),
|
||||||
Remark: buildExtensionLogRemark(
|
Remark: buildExtensionLogRemark(
|
||||||
request.maestro.Name,
|
request!.maestro.Name,
|
||||||
request.AccountType
|
request!.AccountType
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||||
maestroName: request.maestro.Name.trim(),
|
maestroName: request!.maestro.Name.trim(),
|
||||||
maestroEmail: request.maestro.Email.trim(),
|
maestroEmail: request!.maestro.Email.trim(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -160,58 +190,90 @@ 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(
|
||||||
maestroExtensionID: number
|
maestroExtensionID: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
const request = await getActionTarget(tx, maestroExtensionID);
|
const claimed = await tx.maestro_extension.updateMany({
|
||||||
|
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
|
||||||
await tx.maestro_extension.update({
|
|
||||||
where: { MaestroExtensionID: request.MaestroExtensionID },
|
|
||||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
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({
|
await tx.maestro_log.create({
|
||||||
data: {
|
data: {
|
||||||
Type: "cancel_extension_maestro",
|
Type: "cancel_extension_maestro",
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
LogDateTime: new Date(),
|
LogDateTime: new Date(),
|
||||||
Remark: buildExtensionLogRemark(
|
Remark: buildExtensionLogRemark(
|
||||||
request.maestro.Name,
|
request!.maestro.Name,
|
||||||
request.AccountType
|
request!.AccountType
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,25 +288,6 @@ export function parseExtensionRequestSearchParams(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getActionTarget(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
maestroExtensionID: number
|
|
||||||
) {
|
|
||||||
const request = await tx.maestro_extension.findUnique({
|
|
||||||
where: { MaestroExtensionID: maestroExtensionID },
|
|
||||||
include: { maestro: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
|
||||||
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExtensionRequestWhere(
|
function buildExtensionRequestWhere(
|
||||||
params: ExtensionRequestSearchParams
|
params: ExtensionRequestSearchParams
|
||||||
|
|||||||
+96
-45
@@ -5,6 +5,7 @@ import { logger } from "@/lib/logger";
|
|||||||
import { ApiError } from "@/lib/errors";
|
import { ApiError } from "@/lib/errors";
|
||||||
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
||||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
@@ -74,7 +75,6 @@ export type MaestroDetail = {
|
|||||||
players: number;
|
players: number;
|
||||||
extensionRequests: number;
|
extensionRequests: number;
|
||||||
upgradeRequests: number;
|
upgradeRequests: number;
|
||||||
logs: number;
|
|
||||||
};
|
};
|
||||||
players: Array<{
|
players: Array<{
|
||||||
playerID: number;
|
playerID: number;
|
||||||
@@ -96,12 +96,6 @@ export type MaestroDetail = {
|
|||||||
requestedDateTime: string;
|
requestedDateTime: string;
|
||||||
status: number;
|
status: number;
|
||||||
}>;
|
}>;
|
||||||
logs: Array<{
|
|
||||||
maestroLogID: number;
|
|
||||||
type: string;
|
|
||||||
logDateTime: string;
|
|
||||||
remark: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const activateStatusValues = [
|
const activateStatusValues = [
|
||||||
@@ -206,23 +200,21 @@ export async function updateMaestro(
|
|||||||
}
|
}
|
||||||
if (payload.availableActivateDateTime !== undefined) {
|
if (payload.availableActivateDateTime !== undefined) {
|
||||||
const newDate = new Date(payload.availableActivateDateTime);
|
const newDate = new Date(payload.availableActivateDateTime);
|
||||||
if (!isNaN(newDate.getTime())) {
|
if (isNaN(newDate.getTime())) {
|
||||||
const prevMin = Math.floor(
|
throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
|
||||||
maestro.AvailableActivateDateTime.getTime() / 60000
|
}
|
||||||
);
|
const prevMin = Math.floor(
|
||||||
const newMin = Math.floor(newDate.getTime() / 60000);
|
maestro.AvailableActivateDateTime.getTime() / 60000
|
||||||
if (prevMin !== newMin) {
|
);
|
||||||
updates.AvailableActivateDateTime = newDate;
|
const newMin = Math.floor(newDate.getTime() / 60000);
|
||||||
const prevStr = maestro.AvailableActivateDateTime.toISOString().slice(
|
if (prevMin !== newMin) {
|
||||||
0,
|
updates.AvailableActivateDateTime = newDate;
|
||||||
10
|
const prevStr = formatDate(maestro.AvailableActivateDateTime);
|
||||||
);
|
const newStr = formatDate(newDate);
|
||||||
const newStr = newDate.toISOString().slice(0, 10);
|
logs.push({
|
||||||
logs.push({
|
type: "update_maestro_available_date",
|
||||||
type: "update_maestro_available_date",
|
remark: `${prevStr} -> ${newStr}`,
|
||||||
remark: `${prevStr} -> ${newStr}`,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -263,6 +255,86 @@ export async function updateMaestro(
|
|||||||
return { changed: true };
|
return { changed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logPageSizeOptions = [10, 20, 50] as const;
|
||||||
|
type LogPageSize = (typeof logPageSizeOptions)[number];
|
||||||
|
|
||||||
|
const logSearchSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).catch(1),
|
||||||
|
pageSize: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((v) => logPageSizeOptions.includes(v as LogPageSize))
|
||||||
|
.catch(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MaestroLogsResult = {
|
||||||
|
items: Array<{
|
||||||
|
maestroLogID: number;
|
||||||
|
type: string;
|
||||||
|
logDateTime: string;
|
||||||
|
remark: string;
|
||||||
|
}>;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMaestroLogs(
|
||||||
|
maestroID: number,
|
||||||
|
rawSearchParams: RawSearchParams = {}
|
||||||
|
): Promise<MaestroLogsResult | null> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
const maestroExists = await db.maestro.findUnique({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: { MaestroID: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!maestroExists) return null;
|
||||||
|
|
||||||
|
const params = logSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||||
|
const skip = (params.page - 1) * params.pageSize;
|
||||||
|
|
||||||
|
const [totalCount, logs] = await Promise.all([
|
||||||
|
db.maestro_log.count({ where: { MaestroID: maestroID } }),
|
||||||
|
db.maestro_log.findMany({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
orderBy: { MaestroLogID: "desc" },
|
||||||
|
skip,
|
||||||
|
take: params.pageSize,
|
||||||
|
select: {
|
||||||
|
MaestroLogID: true,
|
||||||
|
Type: true,
|
||||||
|
LogDateTime: true,
|
||||||
|
Remark: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||||
|
|
||||||
|
logger.debug("maestros/[id]/logs", "fetched", {
|
||||||
|
id: maestroID,
|
||||||
|
page: params.page,
|
||||||
|
totalCount,
|
||||||
|
duration: Date.now() - t0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: logs.map((log) => ({
|
||||||
|
maestroLogID: log.MaestroLogID,
|
||||||
|
type: log.Type.trim(),
|
||||||
|
logDateTime: log.LogDateTime.toISOString(),
|
||||||
|
remark: log.Remark.trim(),
|
||||||
|
})),
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
totalCount,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const studentPageSizeOptions = [10, 20, 50] as const;
|
const studentPageSizeOptions = [10, 20, 50] as const;
|
||||||
type StudentPageSize = (typeof studentPageSizeOptions)[number];
|
type StudentPageSize = (typeof studentPageSizeOptions)[number];
|
||||||
|
|
||||||
@@ -431,16 +503,13 @@ export async function getMaestroDetail(
|
|||||||
playersCount,
|
playersCount,
|
||||||
extensionRequestsCount,
|
extensionRequestsCount,
|
||||||
upgradeRequestsCount,
|
upgradeRequestsCount,
|
||||||
logsCount,
|
|
||||||
players,
|
players,
|
||||||
extensionRequests,
|
extensionRequests,
|
||||||
upgradeRequests,
|
upgradeRequests,
|
||||||
logs,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.player.count({ where: { MaestroID: maestroID } }),
|
db.player.count({ where: { MaestroID: maestroID } }),
|
||||||
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
|
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
|
||||||
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
|
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
|
||||||
db.maestro_log.count({ where: { MaestroID: maestroID } }),
|
|
||||||
db.player.findMany({
|
db.player.findMany({
|
||||||
where: { MaestroID: maestroID },
|
where: { MaestroID: maestroID },
|
||||||
orderBy: { PlayerID: "desc" },
|
orderBy: { PlayerID: "desc" },
|
||||||
@@ -476,17 +545,6 @@ export async function getMaestroDetail(
|
|||||||
Status: 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", {
|
logger.info("maestros/[id]", "fetched", {
|
||||||
@@ -504,7 +562,6 @@ export async function getMaestroDetail(
|
|||||||
players: playersCount,
|
players: playersCount,
|
||||||
extensionRequests: extensionRequestsCount,
|
extensionRequests: extensionRequestsCount,
|
||||||
upgradeRequests: upgradeRequestsCount,
|
upgradeRequests: upgradeRequestsCount,
|
||||||
logs: logsCount,
|
|
||||||
},
|
},
|
||||||
players: players.map((player) => ({
|
players: players.map((player) => ({
|
||||||
playerID: player.PlayerID,
|
playerID: player.PlayerID,
|
||||||
@@ -526,12 +583,6 @@ export async function getMaestroDetail(
|
|||||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||||
status: request.Status,
|
status: request.Status,
|
||||||
})),
|
})),
|
||||||
logs: logs.map((log) => ({
|
|
||||||
maestroLogID: log.MaestroLogID,
|
|
||||||
type: log.Type.trim(),
|
|
||||||
logDateTime: log.LogDateTime.toISOString(),
|
|
||||||
remark: log.Remark.trim(),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-54
@@ -25,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({
|
|||||||
q: z.string().trim().catch(""),
|
q: z.string().trim().catch(""),
|
||||||
status: z
|
status: z
|
||||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||||
.optional()
|
.default(REQUEST_STATUSES.REQUESTED)
|
||||||
.catch(REQUEST_STATUSES.REQUESTED),
|
.catch(REQUEST_STATUSES.REQUESTED),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export type UpgradeRequestListResult = {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
q: string;
|
q: string;
|
||||||
status?: number | "all";
|
status: number | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUpgradeRequests(
|
export async function getUpgradeRequests(
|
||||||
@@ -121,48 +121,81 @@ export async function approveUpgradeRequest(
|
|||||||
requestedAccountType: number;
|
requestedAccountType: number;
|
||||||
}> {
|
}> {
|
||||||
return db.$transaction(async (tx) => {
|
return db.$transaction(async (tx) => {
|
||||||
const request = await getActionTarget(tx, maestroUpgradeID);
|
// 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, 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({
|
||||||
where: { MaestroID: request.MaestroID },
|
where: { MaestroID: request!.MaestroID },
|
||||||
data: {
|
data: {
|
||||||
AccountType: request.RequestedAccountType,
|
AccountType: request!.RequestedAccountType,
|
||||||
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
||||||
AvailableActivateDateTime: availableActivateDateTime,
|
AvailableActivateDateTime: availableActivateDateTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.maestro_upgrade.updateMany({
|
await tx.maestro_upgrade.updateMany({
|
||||||
where: {
|
where: {
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
Status: REQUEST_STATUSES.REQUESTED,
|
Status: REQUEST_STATUSES.REQUESTED,
|
||||||
},
|
},
|
||||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||||
});
|
});
|
||||||
await tx.maestro_upgrade.update({
|
|
||||||
where: { MaestroUpgradeID: request.MaestroUpgradeID },
|
|
||||||
data: { Status: REQUEST_STATUSES.APPLIED },
|
|
||||||
});
|
|
||||||
await tx.maestro_log.create({
|
await tx.maestro_log.create({
|
||||||
data: {
|
data: {
|
||||||
Type: "upgrade_maestro",
|
Type: "upgrade_maestro",
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
LogDateTime: new Date(),
|
LogDateTime: new Date(),
|
||||||
Remark: buildUpgradeLogRemark(
|
Remark: buildUpgradeLogRemark(
|
||||||
request.RegisteredActivateStatus,
|
request!.RegisteredActivateStatus,
|
||||||
request.RegisteredAccountType,
|
request!.RegisteredAccountType,
|
||||||
request.RequestedAccountType
|
request!.RequestedAccountType
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||||
maestroName: request.maestro.Name.trim(),
|
maestroName: request!.maestro.Name.trim(),
|
||||||
maestroEmail: request.maestro.Email.trim(),
|
maestroEmail: request!.maestro.Email.trim(),
|
||||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
registeredActivateStatus: request!.RegisteredActivateStatus,
|
||||||
registeredAccountType: request.RegisteredAccountType,
|
registeredAccountType: request!.RegisteredAccountType,
|
||||||
requestedAccountType: request.RequestedAccountType,
|
requestedAccountType: request!.RequestedAccountType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -181,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({
|
||||||
@@ -202,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,
|
||||||
@@ -215,21 +271,39 @@ export async function rejectUpgradeRequest(
|
|||||||
maestroUpgradeID: number
|
maestroUpgradeID: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
const request = await getActionTarget(tx, maestroUpgradeID);
|
const claimed = await tx.maestro_upgrade.updateMany({
|
||||||
|
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
|
||||||
await tx.maestro_upgrade.update({
|
|
||||||
where: { MaestroUpgradeID: request.MaestroUpgradeID },
|
|
||||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
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({
|
await tx.maestro_log.create({
|
||||||
data: {
|
data: {
|
||||||
Type: "reject_upgrade_maestro",
|
Type: "reject_upgrade_maestro",
|
||||||
MaestroID: request.MaestroID,
|
MaestroID: request!.MaestroID,
|
||||||
LogDateTime: new Date(),
|
LogDateTime: new Date(),
|
||||||
Remark: buildUpgradeLogRemark(
|
Remark: buildUpgradeLogRemark(
|
||||||
request.RegisteredActivateStatus,
|
request!.RegisteredActivateStatus,
|
||||||
request.RegisteredAccountType,
|
request!.RegisteredAccountType,
|
||||||
request.RequestedAccountType
|
request!.RequestedAccountType
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -244,31 +318,6 @@ export function parseUpgradeRequestSearchParams(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getActionTarget(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
maestroUpgradeID: number
|
|
||||||
) {
|
|
||||||
const request = await tx.maestro_upgrade.findUnique({
|
|
||||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
|
||||||
include: { maestro: { select: { Name: true, Email: true } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
throw new ApiError(
|
|
||||||
"업그레이드 신청을 찾을 수 없습니다.",
|
|
||||||
404
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
|
||||||
throw new ApiError(
|
|
||||||
"이미 처리된 업그레이드 신청입니다.",
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUpgradeRequestWhere(
|
function buildUpgradeRequestWhere(
|
||||||
params: UpgradeRequestSearchParams
|
params: UpgradeRequestSearchParams
|
||||||
|
|||||||
Reference in New Issue
Block a user