체험 계정도 업그레이드 승인되도록 수정
This commit is contained in:
@@ -1,127 +1,127 @@
|
|||||||
# This is NOT the Next.js you know
|
# 당신이 알고 있는 Next.js가 아닙니다
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
이 버전에는 파괴적 변경이 포함되어 있습니다 — API, 관례, 파일 구조가 학습 데이터와 다를 수 있습니다. 코드를 작성하기 전에 반드시 `node_modules/next/dist/docs/` 안의 관련 가이드를 읽으세요. 사용 중단(deprecation) 안내도 놓치지 마세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project: chocoadmin
|
# 프로젝트: chocoadmin
|
||||||
|
|
||||||
Admin panel for the chocomae service (mouse-typing), extracted into a standalone Next.js 16 app. It shares the existing MariaDB database — **do not modify the DB schema**.
|
chocomae 서비스(mouse-typing)의 관리자 패널을 독립 Next.js 16 앱으로 분리한 프로젝트입니다. 기존 MariaDB 데이터베이스를 공유합니다 — **DB 스키마를 변경하지 마세요**.
|
||||||
|
|
||||||
## Critical rules
|
## 필수 규칙
|
||||||
|
|
||||||
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
|
- **DB 스키마를 절대 변경하지 마세요.** 모든 테이블은 운영 중인 chocomae 서비스와 공유됩니다.
|
||||||
- **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.
|
- **승인/취소/거절 경합 방지**: 트랜잭션 내부의 첫 단계로 조건부 `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })`를 사용하세요. `count === 0`이면 존재 여부를 조회해 404 또는 409를 반환합니다. 읽고-쓰기(findUnique → 상태 확인 → update) 방식은 절대 사용하지 마세요 — 동시 요청 시 중복 처리가 발생합니다.
|
||||||
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
|
- **모든 변경은 트랜잭션으로 감쌉니다**: `maestro`, `maestro_extension`/`maestro_upgrade`, `maestro_log`는 원자적으로 함께 갱신되어야 합니다.
|
||||||
- **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.
|
- `maestro_extension`이나 `maestro_upgrade`를 조회할 때 **`include: { maestro: true }`를 절대 사용하지 마세요**. 항상 `select`로 필요한 컬럼만 지정해 maestro의 `Password` 필드가 로그에 남지 않도록 합니다.
|
||||||
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
|
- 관리자 암호 검증은 MariaDB의 `PASSWORD()` 함수를 사용합니다 — 기존 `admin` 테이블은 이 방식으로 해시된 암호를 저장합니다. 마이그레이션 계획 없이는 변경하지 마세요.
|
||||||
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
|
- 연장 승인 만료일 계산은 **서버 사이드**에서 이루어집니다: 현재 만료일이 과거이면 오늘 + 1년, 그렇지 않으면 현재 만료일 + 1년.
|
||||||
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
|
- 업그레이드 승인은 `AvailableActivateDateTime = NOW() + 1 year`로 설정합니다. **업그레이드 시 `PlayerCount`를 변경하지 마세요**.
|
||||||
- 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`.
|
- 연장 또는 업그레이드를 승인할 때는, 동일한 `MaestroID`의 다른 `Status = 1` 요청들을 모두 `Status = 2`로 닫은 뒤 승인 대상 요청을 `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 컨테이너는 `TZ: Asia/Seoul`을 설정해야 합니다** — 그래야 날짜 계산(`setHours(0,0,0,0)`, `formatDate`)이 공유 MariaDB의 KST 기반 기존 데이터와 일치합니다.
|
||||||
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage` — **never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
|
- **Docker 서비스 이름은 환경별로 고유해야 합니다**: Docker는 공유 네트워크에서 각 `services:` 키를 DNS 별칭으로 등록합니다. 스테이지 서비스는 `chocoadmin-stage`입니다 — **`chocoadmin`으로 이름을 바꾸지 마세요**. 이름이 동일하면 `proxy-network`에서 DNS 라운드 로빈이 발생하며, NPM의 `set $server "chocoadmin"`이 요청을 두 컨테이너 사이로 무작위 라우팅해 운영과 스테이지 트래픽이 섞입니다.
|
||||||
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
|
- **서버 액션은 반드시 모듈 최상위 named export여야 합니다**: 인라인 `action={async () => { "use server"; ... }}` 클로저는 빌드마다 새 액션 ID를 얻습니다. 재배포 후 브라우저가 오래된 ID를 보내면 Next.js는 `Failed to find Server Action`으로 응답합니다. 항상 별도의 `actions.ts` 파일(예: `app/(admin)/actions.ts`)에 최상위 export로 추출하세요.
|
||||||
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
|
- **재배포 후 NPM nginx를 재로드하세요**: NAS에서 `docker exec npm nginx -s reload`를 실행해 업스트림 DNS 캐시를 비워야 합니다. 이 단계를 빠뜨리면 NPM이 이전 컨테이너 IP로 계속 라우팅할 수 있습니다.
|
||||||
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) accept `ActivateStatus = 2` (ACTIVE) or `1` (TRIAL). PENDING (0) and CANCELED (100) are rejected with 400. `AccountType` is not restricted — any type (paid tier or trial type) may request an extension. Approval (`PATCH /api/extension-requests/[id]`) mirrors this — accepts ACTIVE and TRIAL, promotes trial to ACTIVE on approval. PENDING/CANCELED throw 409 (transaction rolls back the atomic claim).
|
- **연장 신청**(`POST /api/maestros/[id]/extension-requests`)은 `ActivateStatus = 2`(ACTIVE) 또는 `1`(TRIAL)을 허용합니다. PENDING(0)과 CANCELED(100)은 400으로 거부합니다. `AccountType`은 제한하지 않습니다 — 어떤 유형(유료 티어 또는 체험 티어)이든 연장 신청 가능합니다. 승인(`PATCH /api/extension-requests/[id]`)도 동일하게 ACTIVE와 TRIAL을 허용하며, 체험 계정은 승인 시 ACTIVE로 승격됩니다. PENDING/CANCELED는 409를 던집니다(원자 클레임 트랜잭션이 롤백됨).
|
||||||
- **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.
|
- **업그레이드 신청**: 대상(`requestedAccountType`)은 반드시 유료 티어(1~5)여야 합니다. 현재 `AccountType`이 체험 티어(100/101)이면 어떤 유료 티어로도 업그레이드 가능합니다. 현재 `AccountType`이 유료 티어이면, TRIAL 계정(`ActivateStatus = 1`)은 **동일 티어 이상**(체험→유료 전환, `requestedAccountType >= AccountType`), ACTIVE 계정은 엄격히 **상위 티어**(`>`)만 가능합니다. 승인 시점에 이 규칙이 마에스트로의 **현재** `AccountType`을 기준으로 재검증되며, 유효하지 않게 된 경우(예: 관리자가 직접 티어를 올린 경우) 승인은 409를 던지고 롤백됩니다.
|
||||||
- Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional.
|
- 관리자 주도 연장/업그레이드 신청 등록은 이메일을 발송하지 **않습니다**(셀프 등록 흐름은 결제 안내 이메일을 발송함). 이는 의도된 동작입니다.
|
||||||
|
|
||||||
## Authentication
|
## 인증
|
||||||
|
|
||||||
- NextAuth.js v5 (`next-auth@5.0.0-beta.31`) with Credentials Provider.
|
- Credentials Provider를 사용하는 NextAuth.js v5(`next-auth@5.0.0-beta.31`).
|
||||||
- Config lives in `auth.ts` (project root), not inside `app/`.
|
- 설정은 `app/` 내부가 아닌 프로젝트 루트의 `auth.ts`에 있습니다.
|
||||||
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
|
- 미인증 요청은 `proxy.ts`(미들웨어)에서 잡혀 `/login`으로 리다이렉트됩니다.
|
||||||
- API routes must call `auth()` and return 401 if no session.
|
- API 라우트는 `auth()`를 호출하고 세션이 없으면 401을 반환해야 합니다.
|
||||||
- Session `maxAge` is 8 hours — do not lengthen without review.
|
- 세션 `maxAge`는 8시간입니다 — 검토 없이 늘리지 마세요.
|
||||||
- `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.
|
- `auth.ts`는 관리자 이름별 로그인 실패 횟수를 인메모리 `Map`으로 추적합니다. 연속 5회 실패 시 15분 동안 잠금되며, 성공 시 카운터가 초기화됩니다.
|
||||||
- Session cookie name `chocoadmin-${APP_ENV}.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly.
|
- 세션 쿠키 이름 `chocoadmin-${APP_ENV}.session-token`은 `auth.ts`(`cookies.sessionToken.name`)와 `proxy.ts`(`getToken({ cookieName })`) **양쪽 모두**에 설정되어야 합니다. `auth.ts`에만 설정하면 `proxy.ts`가 기본 쿠키 이름을 찾지만 그 이름은 절대 기록되지 않아 → 미들웨어가 모든 요청을 `/login`으로 리다이렉트 → 로그인이 다시 `/`로 리다이렉트 → 무한 `ERR_TOO_MANY_REDIRECTS`가 발생합니다. NextAuth v5에서는 JWT 암호화 salt가 쿠키 이름과 동일하므로, 두 곳이 정확히 일치해야 합니다.
|
||||||
- Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop.
|
- 쿠키 이름에 `__Secure-` 접두사를 붙이지 **마세요**. Next.js 16 미들웨어는 (Edge가 아닌) Node.js 런타임에서 실행되며 Nginx Proxy Manager로부터 HTTP를 받습니다 — `__Secure-` 쿠키는 HTTP에서 조용히 거부되어 동일한 리다이렉트 루프를 만듭니다.
|
||||||
|
|
||||||
## Key files
|
## 핵심 파일
|
||||||
|
|
||||||
| File | Purpose |
|
| 파일 | 용도 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) |
|
| `auth.ts` | NextAuth 설정 (Credentials Provider, MariaDB PASSWORD 검증) |
|
||||||
| `proxy.ts` | Middleware — redirects unauthenticated users to /login |
|
| `proxy.ts` | 미들웨어 — 미인증 사용자를 /login으로 리다이렉트 |
|
||||||
| `lib/db.ts` | Prisma Client singleton with SQL pretty-logging and result table logging |
|
| `lib/db.ts` | SQL 프리티 로깅 + 결과 표 로깅이 포함된 Prisma Client 싱글톤 |
|
||||||
| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` |
|
| `lib/logger.ts` | 구조화 로거 — `debug/info/warn/error`, 레벨은 `LOG_LEVEL` / `APP_ENV`로 제어 |
|
||||||
| `lib/errors.ts` | `ApiError` — unified error class with HTTP status code |
|
| `lib/errors.ts` | `ApiError` — HTTP 상태 코드가 포함된 통합 에러 클래스 |
|
||||||
| `lib/api-handler.ts` | `withApiHandler` — wraps route handlers with auth, timing, and error logging |
|
| `lib/api-handler.ts` | `withApiHandler` — 라우트 핸들러를 인증·타이밍·에러 로깅으로 감쌈 |
|
||||||
| `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`); `PAID_ACCOUNT_TYPE_VALUES` (유료 계정 유형 배열 — 중복 정의 방지용 공용 상수) |
|
| `lib/constants.ts` | 상태 코드 상수 (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`); `PAID_ACCOUNT_TYPE_VALUES` (유료 계정 유형 배열 — 중복 정의 방지용 공용 상수) |
|
||||||
| `lib/maestros.ts` | Maestro DB queries |
|
| `lib/maestros.ts` | Maestro DB 쿼리 |
|
||||||
| `lib/extension-requests.ts` | Extension request queries and approval logic |
|
| `lib/extension-requests.ts` | 연장 신청 쿼리 및 승인 로직 |
|
||||||
| `lib/upgrade-requests.ts` | Upgrade request queries and approval logic |
|
| `lib/upgrade-requests.ts` | 업그레이드 신청 쿼리 및 승인 로직 |
|
||||||
| `prisma/schema.prisma` | Generated via `prisma db pull` — do not hand-edit model fields |
|
| `prisma/schema.prisma` | `prisma db pull`로 생성됨 — 모델 필드를 직접 편집하지 마세요 |
|
||||||
| `docs/business-rules.md` | Full reference for existing chocomae business logic |
|
| `docs/business-rules.md` | 기존 chocomae 비즈니스 로직의 전체 참조 |
|
||||||
| `docs/phase/phase9.md` | Log policy: levels, format, masking rules, Docker log operations |
|
| `docs/phase/phase9.md` | 로그 정책: 레벨, 형식, 마스킹 규칙, Docker 로그 운영 |
|
||||||
|
|
||||||
## maestro_log conventions
|
## maestro_log 관례
|
||||||
|
|
||||||
Always record a log entry after a successful DB change. Use these `Type` values:
|
DB 변경이 성공한 뒤에는 반드시 로그 항목을 기록합니다. 다음 `Type` 값을 사용하세요:
|
||||||
|
|
||||||
| Type | When |
|
| Type | 사용 시점 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `extension_maestro` | Extension approved |
|
| `extension_maestro` | 연장 승인 |
|
||||||
| `cancel_extension_maestro` | Extension cancelled (new type, not in original) |
|
| `cancel_extension_maestro` | 연장 취소 (새 타입, 원본에는 없음) |
|
||||||
| `upgrade_maestro` | Upgrade approved |
|
| `upgrade_maestro` | 업그레이드 승인 |
|
||||||
| `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) |
|
| `reject_upgrade_maestro` | 업그레이드 거절 (새 타입, 원본에는 없음) |
|
||||||
| `update_maestro_name` | Maestro name changed | `{prev} -> {new}` |
|
| `update_maestro_name` | Maestro 이름 변경 | `{prev} -> {new}` |
|
||||||
| `update_maestro_email` | Maestro email changed | `{prev} -> {new}` |
|
| `update_maestro_email` | Maestro 이메일 변경 | `{prev} -> {new}` |
|
||||||
| `update_maestro_status` | ActivateStatus changed (admin, new) | `{prev} -> {new}` (numeric) |
|
| `update_maestro_status` | ActivateStatus 변경 (관리자, 새 항목) | `{prev} -> {new}` (숫자) |
|
||||||
| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) |
|
| `update_maestro_account_type` | AccountType 변경 (관리자, 새 항목) | `{prev} -> {new}` (숫자) |
|
||||||
| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
|
| `update_maestro_available_date` | AvailableActivateDateTime 변경 (관리자, 새 항목) | `{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 변경 (관리자, 새 항목) | `{prev} -> {new}` (0 또는 1) |
|
||||||
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
|
| `request_extension_maestro` | 관리자 주도 연장 신청 생성 | `{maestroName}({accountTypeLabel})` |
|
||||||
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
|
| `request_upgrade_maestro` | 관리자 주도 업그레이드 신청 생성 | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
|
||||||
| `reset_maestro_password` | Maestro password reset to 123456 by admin | `admin reset to 123456` |
|
| `reset_maestro_password` | 관리자가 Maestro 암호를 123456으로 초기화 | `admin reset to 123456` |
|
||||||
|
|
||||||
`Remark` column is `char(100)` — keep values under 100 characters.
|
`Remark` 컬럼은 `char(100)`입니다 — 값을 100자 이내로 유지하세요.
|
||||||
|
|
||||||
## Implemented phases (do not re-implement)
|
## 구현된 페이즈 (재구현 금지)
|
||||||
|
|
||||||
- Phase 0–8: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
|
- 페이즈 0–8: 로그인, 마에스트로 목록/상세, 연장 신청, 업그레이드 신청, Docker 셋업, 트랜잭션 안전성, 인증 가드.
|
||||||
- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification.
|
- 페이즈 9 (대부분 완료): 구조화 로깅(`lib/logger.ts`), `withApiHandler` 래퍼(`lib/api-handler.ts`), `ApiError` 클래스(`lib/errors.ts`), Prisma SQL 프리티 로깅 및 결과 표 로깅(`lib/db.ts`). 남은 항목: 상관관계 ID, 스테이지/운영 로그 검증.
|
||||||
- Phase 10: Maestro info edit form (`PATCH /api/maestros/[id]`) + student list with server-side pagination (`GET /api/maestros/[id]/students`).
|
- 페이즈 10: 마에스트로 정보 수정 폼(`PATCH /api/maestros/[id]`) + 서버사이드 페이지네이션 학생 목록(`GET /api/maestros/[id]/students`).
|
||||||
- Phase 11: Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`). Upgrade email edge-case verification (trial→paid, paid→paid) still pending.
|
- 페이즈 11: 연장/업그레이드 승인 후 Nodemailer 자동 이메일 발송(`lib/mail.ts`). 업그레이드 이메일의 엣지 케이스(체험→유료, 유료→유료) 검증은 아직 미완료.
|
||||||
- Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page.
|
- 페이즈 12: 마에스트로 상세 화면에서 관리자 주도 연장/업그레이드 신청 등록.
|
||||||
- `POST /api/maestros/[id]/extension-requests` — inserts into `maestro_extension` using maestro's current `AccountType` read server-side.
|
- `POST /api/maestros/[id]/extension-requests` — 서버 사이드에서 읽어온 마에스트로의 현재 `AccountType`을 사용해 `maestro_extension`에 삽입.
|
||||||
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
- `POST /api/maestros/[id]/upgrade-requests` — 트랜잭션 내부에서 `maestro_upgrade`에 삽입; `requestedAccountType > maestro.AccountType`을 검증.
|
||||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — 인라인 액션 UI를 가진 클라이언트 컴포넌트; 성공 시 `router.refresh()` 호출.
|
||||||
- `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`를 `lib/constants.ts`로 통합 — `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, 새 API 라우트 두 개가 공유.
|
||||||
- Phase 15:
|
- 페이즈 15:
|
||||||
- Password reset button (`암호 초기화`) added to `MaestroEditForm.tsx` — calls `POST /api/maestros/[id]/reset-password`, shows `window.confirm` with maestro name, displays inline success/error message.
|
- `MaestroEditForm.tsx`에 `암호 초기화` 버튼 추가 — `POST /api/maestros/[id]/reset-password`를 호출하며, 마에스트로 이름과 함께 `window.confirm`을 표시하고 성공/에러 메시지를 인라인으로 노출.
|
||||||
- `resetMaestroPassword(maestroID)` added to `lib/maestros.ts` — runs `UPDATE maestro SET Password = PASSWORD('123456')` inside a `$transaction` with a `reset_maestro_password` log entry.
|
- `lib/maestros.ts`에 `resetMaestroPassword(maestroID)` 추가 — `$transaction` 안에서 `UPDATE maestro SET Password = PASSWORD('123456')`을 실행하며 `reset_maestro_password` 로그 항목을 남김.
|
||||||
- `app/api/maestros/[id]/reset-password/route.ts` — `POST` handler wrapped with `withApiHandler`.
|
- `app/api/maestros/[id]/reset-password/route.ts` — `withApiHandler`로 감싼 `POST` 핸들러.
|
||||||
- Deployment shell scripts added: `scripts/deploy-stage.sh` and `scripts/deploy-production.sh` — each runs git pull, ensures proxy-network, `docker compose up -d --build --remove-orphans`, and `docker exec npm nginx -s reload`. Production script requires `yes` confirmation.
|
- 배포 셸 스크립트 추가: `scripts/deploy-stage.sh`와 `scripts/deploy-production.sh` — 각각 git pull, proxy-network 확보, `docker compose up -d --build --remove-orphans`, `docker exec npm nginx -s reload`를 실행. 운영 스크립트는 `yes` 확인이 필요.
|
||||||
- Phase 13 (security/correctness fixes):
|
- 페이즈 13 (보안/정확성 수정):
|
||||||
- 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-1: `lib/extension-requests.ts`의 `getActionTarget`을 인라인 `select`로 교체(`include: { maestro: true }` 제거) — 디버그 결과 로그에 `Password`가 노출되지 않도록 함.
|
||||||
- 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-2: `lib/extension-requests.ts`와 `lib/upgrade-requests.ts` 양쪽의 승인/취소/거절 흐름에서 원자적 `updateMany({ Status: REQUESTED → target })` 가드를 사용; `count === 0`이면 404/409를 트리거 — 동시 요청에 의한 중복 처리 제거.
|
||||||
- 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.
|
- P0-3: `docker-compose.yml`과 `docker-compose.stage.yml`에 `TZ: Asia/Seoul` 설정; `lib/maestros.ts`의 날짜 변경 로그 remark는 `toISOString().slice(0,10)` 대신 `formatDate()`를 사용해 KST 일관성 확보.
|
||||||
- P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade.
|
- P1-1/P1-2: `createExtensionRequest`와 `approveExtensionRequest`는 `ActivateStatus === ACTIVE`를 요구; `UpgradeRequestsSection`과 `createUpgradeRequest`는 TRIAL 동일 티어 업그레이드를 허용.
|
||||||
- P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time.
|
- P1-3: `approveUpgradeRequest`는 승인 시점에 마에스트로의 현재 `AccountType`을 기준으로 업그레이드 방향을 재검증.
|
||||||
- P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries.
|
- P1-4/P1-5: `createExtensionRequest`를 `db.$transaction`으로 감싸고, 두 create 함수 모두 `request_extension_maestro` / `request_upgrade_maestro` 로그 항목을 기록.
|
||||||
- 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`.
|
- P1-6: 연장/업그레이드 승인 라우트는 이제 이메일 발송을 `await`하며 응답에 `emailSent: boolean`을 포함; 테이블 UI는 `emailSent === false`일 때 인라인 앰버 경고를 표시.
|
||||||
- P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed.
|
- P2-2: `updateMaestro`는 이제 `availableActivateDateTime`을 파싱하지 못하면 조용히 건너뛰는 대신 400을 반환.
|
||||||
- P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses).
|
- P2-5: `withApiHandler`의 catch 블록은 재던지기(HTML 응답을 만들었음) 대신 `{ message: "Internal server error" }` JSON 500을 반환.
|
||||||
- P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`).
|
- P2-6: NextAuth 세션 `maxAge`를 8시간으로 설정(`auth.ts`).
|
||||||
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
|
- P2-7: `auth.ts`에 인메모리 무차별 대입 방어 추가 — 5회 실패 → 관리자 이름별 15분 잠금.
|
||||||
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
|
- P2-8: 연장/업그레이드 신청 목록의 기본 `status` 필터를 `undefined`(전체)에서 `REQUEST_STATUSES.REQUESTED`로 변경; 리셋 링크도 `?status=1`로 업데이트.
|
||||||
|
|
||||||
- Phase 16: 반응형 웹 디자인 (Responsive Web Design)
|
- 페이즈 16: 반응형 웹 디자인 (Responsive Web Design)
|
||||||
- `app/(admin)/nav-config.ts` — navItems 배열 공유 설정 파일 (`layout.tsx`·`MobileNav.tsx` 공용)
|
- `app/(admin)/nav-config.ts` — navItems 배열 공유 설정 파일 (`layout.tsx`·`MobileNav.tsx` 공용)
|
||||||
- `app/(admin)/MobileNav.tsx` — 모바일 슬라이드인 사이드바 클라이언트 컴포넌트. 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), X 버튼, ESC 키, pathname 변경 시 자동 닫힘, body 스크롤 잠금 포함. CSS `translate-x` 트랜지션으로 애니메이션.
|
- `app/(admin)/MobileNav.tsx` — 모바일 슬라이드인 사이드바 클라이언트 컴포넌트. 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), X 버튼, ESC 키, pathname 변경 시 자동 닫힘, body 스크롤 잠금 포함. CSS `translate-x` 트랜지션으로 애니메이션.
|
||||||
- `app/(admin)/layout.tsx` — 헤더에 `<MobileNav />` 통합. 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지.
|
- `app/(admin)/layout.tsx` — 헤더에 `<MobileNav />` 통합. 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지.
|
||||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — `<section>`에 `overflow-hidden` 추가. SSR props로 표가 초기 렌더에 포함되어 `min-w-[560px]`가 섹션을 밀어내던 문제 수정. 헤더를 `flex-col sm:flex-row`로 변경해 모바일에서 액션 버튼 접근성 확보.
|
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — `<section>`에 `overflow-hidden` 추가. SSR props로 표가 초기 렌더에 포함되어 `min-w-[560px]`가 섹션을 밀어내던 문제 수정. 헤더를 `flex-col sm:flex-row`로 변경해 모바일에서 액션 버튼 접근성 확보.
|
||||||
- 페이지네이션 `<nav>` 5개 파일(`extension-requests/page.tsx`, `upgrade-requests/page.tsx`, `maestros/page.tsx`, `StudentsSection.tsx`, `LogsSection.tsx`) — `self-end sm:self-auto` 추가로 모바일에서 오른쪽 정렬.
|
- 페이지네이션 `<nav>` 5개 파일(`extension-requests/page.tsx`, `upgrade-requests/page.tsx`, `maestros/page.tsx`, `StudentsSection.tsx`, `LogsSection.tsx`) — `self-end sm:self-auto` 추가로 모바일에서 오른쪽 정렬.
|
||||||
|
|
||||||
## Planned phases (not yet implemented)
|
## 계획된 페이즈 (미구현)
|
||||||
|
|
||||||
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
|
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
|
||||||
|
|
||||||
## Packages to know
|
## 알아둘 패키지
|
||||||
|
|
||||||
- `mariadb` + `@prisma/adapter-mariadb` — Prisma uses the mariadb driver directly (not mysql2)
|
- `mariadb` + `@prisma/adapter-mariadb` — Prisma는 mysql2가 아닌 mariadb 드라이버를 직접 사용
|
||||||
- `@base-ui/react` — used instead of Radix UI primitives for shadcn components
|
- `@base-ui/react` — shadcn 컴포넌트에서 Radix UI 프리미티브 대신 사용
|
||||||
- `zod` v4 — Zod v4 API differs from v3; check docs before writing schemas
|
- `zod` v4 — Zod v4의 API는 v3와 다름; 스키마 작성 전 문서 확인
|
||||||
- `next-auth` v5 beta — API surface differs significantly from v4
|
- `next-auth` v5 beta — v4와 API 표면이 크게 다름
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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, ACTIVATE_STATUSES } from "@/lib/constants";
|
import { ACCOUNT_TYPES, ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } 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";
|
||||||
|
|
||||||
@@ -42,9 +42,14 @@ export function UpgradeRequestsSection({
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const isTrial = activateStatus === ACTIVATE_STATUSES.TRIAL;
|
const isTrial = activateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||||
|
const isCurrentPaid = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
|
||||||
const isUpgradeEnabled =
|
const isUpgradeEnabled =
|
||||||
selectedAccountType !== null &&
|
selectedAccountType !== null &&
|
||||||
(isTrial ? selectedAccountType >= accountType : selectedAccountType > accountType);
|
(!isCurrentPaid
|
||||||
|
? true
|
||||||
|
: isTrial
|
||||||
|
? selectedAccountType >= accountType
|
||||||
|
: selectedAccountType > accountType);
|
||||||
const isDisabled = isCreating || isPending;
|
const isDisabled = isCreating || isPending;
|
||||||
|
|
||||||
async function handleUpgrade() {
|
async function handleUpgrade() {
|
||||||
@@ -111,7 +116,13 @@ export function UpgradeRequestsSection({
|
|||||||
<option value="">요금제 선택</option>
|
<option value="">요금제 선택</option>
|
||||||
{upgradeOptions.map((opt) => (
|
{upgradeOptions.map((opt) => (
|
||||||
<option
|
<option
|
||||||
disabled={isTrial ? opt.value < accountType : opt.value <= accountType}
|
disabled={
|
||||||
|
isCurrentPaid
|
||||||
|
? isTrial
|
||||||
|
? opt.value < accountType
|
||||||
|
: opt.value <= accountType
|
||||||
|
: false
|
||||||
|
}
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
>
|
>
|
||||||
|
|||||||
+73
-73
@@ -1,16 +1,16 @@
|
|||||||
# chocoadmin Deployment
|
# chocoadmin 배포
|
||||||
|
|
||||||
## 1. Environment Files
|
## 1. 환경 파일
|
||||||
|
|
||||||
Create environment files on the Synology NAS. Do not commit real `.env.*` files.
|
Synology NAS에서 환경 파일을 생성합니다. 실제 `.env.*` 파일은 커밋하지 마세요.
|
||||||
|
|
||||||
Stage file path:
|
스테이지 파일 경로:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/volume1/docker/service/jinaju/chocoadmin/.env.stage
|
/volume1/docker/service/jinaju/chocoadmin/.env.stage
|
||||||
```
|
```
|
||||||
|
|
||||||
Stage example:
|
스테이지 예시:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
|
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
|
||||||
@@ -20,13 +20,13 @@ AUTH_URL="https://chocoadmin-stage.jisangs.com"
|
|||||||
NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
|
NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
Production file path:
|
운영 파일 경로:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/volume1/docker/service/jinaju/chocoadmin/.env.production
|
/volume1/docker/service/jinaju/chocoadmin/.env.production
|
||||||
```
|
```
|
||||||
|
|
||||||
Production example:
|
운영 예시:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@chocomae.jinaju.com:3306/chocomae"
|
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@chocomae.jinaju.com:3306/chocomae"
|
||||||
@@ -36,36 +36,36 @@ AUTH_URL="https://chocoadmin.jinaju.com"
|
|||||||
NEXTAUTH_URL="https://chocoadmin.jinaju.com"
|
NEXTAUTH_URL="https://chocoadmin.jinaju.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
`AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
|
`AUTH_SECRET`과 `NEXTAUTH_SECRET`은 Auth.js 호환을 위해 각 환경 내에서 동일한 강력한 랜덤 값이어야 합니다. 운영과 스테이지 사이에는 서로 다른 값을 사용하세요.
|
||||||
|
|
||||||
`APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`chocoadmin-${APP_ENV}.session-token`), which keeps production and stage cookies separate even if they share a browser.
|
`APP_ENV`(`production` / `stage`)는 `docker-compose*.yml`의 `environment` 블록에서 설정합니다 — env 파일에 넣지 **마세요**. 이 값이 세션 쿠키 이름(`chocoadmin-${APP_ENV}.session-token`)을 결정하며, 브라우저를 공유하더라도 운영과 스테이지 쿠키를 분리해 줍니다.
|
||||||
|
|
||||||
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
|
공개 URL이 HTTPS를 사용하면 `AUTH_URL`과 `NEXTAUTH_URL` 모두 외부 HTTPS URL과 정확히 일치해야 합니다.
|
||||||
|
|
||||||
Generate a secret on the NAS:
|
NAS에서 시크릿 생성:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Synology Stage Deployment
|
## 2. Synology 스테이지 배포
|
||||||
|
|
||||||
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
|
운영과 스테이지 모두 `/volume1/docker/service/jinaju/chocoadmin`의 동일한 Git 체크아웃을 공유합니다. 여기에서 pull하세요:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /volume1/docker/service/jinaju/chocoadmin-stage
|
cd /volume1/docker/service/jinaju/chocoadmin-stage
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
|
스테이지는 `docker-compose.stage.yml`, `chocoadmin-stage` 컨테이너, 외부 Docker 네트워크 `proxy-network`를 사용합니다.
|
||||||
|
|
||||||
Verify or create the network:
|
네트워크 확인 또는 생성:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker network inspect proxy-network >/dev/null 2>&1 || docker network create proxy-network
|
docker network inspect proxy-network >/dev/null 2>&1 || docker network create proxy-network
|
||||||
```
|
```
|
||||||
|
|
||||||
Start or update stage:
|
스테이지 시작 또는 업데이트:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.stage.yml up -d --build
|
docker compose -f docker-compose.stage.yml up -d --build
|
||||||
@@ -73,36 +73,36 @@ docker compose -f docker-compose.stage.yml ps
|
|||||||
docker compose -f docker-compose.stage.yml logs -f
|
docker compose -f docker-compose.stage.yml logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
If a service was renamed in the compose file, add `--remove-orphans` once to remove the stale container.
|
compose 파일에서 서비스 이름을 바꾼 경우, 오래된 컨테이너를 제거하기 위해 `--remove-orphans`를 한 번 추가하세요.
|
||||||
|
|
||||||
After redeployment, reload NPM to flush its upstream DNS cache:
|
재배포 후 NPM의 업스트림 DNS 캐시를 비우기 위해 재로드합니다:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec npm nginx -s reload
|
docker exec npm nginx -s reload
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.stage.yml` exposes container port `3000` to `proxy-network` instead of binding host port `3000`, because host port `3000` may already be used by another service such as Gitea.
|
`docker-compose.stage.yml`은 호스트 포트 `3000`을 바인딩하는 대신 컨테이너 포트 `3000`을 `proxy-network`에 노출합니다 — 호스트 포트 `3000`이 Gitea 같은 다른 서비스에서 이미 사용 중일 수 있기 때문입니다.
|
||||||
|
|
||||||
Nginx Proxy Manager settings:
|
Nginx Proxy Manager 설정:
|
||||||
|
|
||||||
- Scheme: `http`
|
- Scheme: `http`
|
||||||
- Forward Hostname / IP: `chocoadmin-stage`
|
- Forward Hostname / IP: `chocoadmin-stage`
|
||||||
- Forward Port: `3000`
|
- Forward Port: `3000`
|
||||||
- Websockets Support: enabled
|
- Websockets Support: 활성화
|
||||||
- SSL: enabled
|
- SSL: 활성화
|
||||||
- Force SSL: enabled
|
- Force SSL: 활성화
|
||||||
|
|
||||||
Stage URL:
|
스테이지 URL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://chocoadmin-stage.jisangs.com
|
https://chocoadmin-stage.jisangs.com
|
||||||
```
|
```
|
||||||
|
|
||||||
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
|
`AUTH_URL`, `NEXTAUTH_URL` 또는 쿠키 관련 설정을 변경한 뒤에는 스테이지 도메인의 브라우저 쿠키를 지우거나 시크릿 창에서 테스트하세요.
|
||||||
|
|
||||||
## 3. Production Deployment
|
## 3. 운영 배포
|
||||||
|
|
||||||
Production uses `docker-compose.yml` and reads `.env.production` by default:
|
운영은 `docker-compose.yml`을 사용하며 기본적으로 `.env.production`을 읽습니다:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /volume1/docker/service/jinaju/chocoadmin
|
cd /volume1/docker/service/jinaju/chocoadmin
|
||||||
@@ -112,32 +112,32 @@ docker compose ps
|
|||||||
docker compose logs -f chocoadmin
|
docker compose logs -f chocoadmin
|
||||||
```
|
```
|
||||||
|
|
||||||
After redeployment, reload NPM:
|
재배포 후 NPM 재로드:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec npm nginx -s reload
|
docker exec npm nginx -s reload
|
||||||
```
|
```
|
||||||
|
|
||||||
Nginx Proxy Manager settings:
|
Nginx Proxy Manager 설정:
|
||||||
|
|
||||||
- Scheme: `http`
|
- Scheme: `http`
|
||||||
- Forward Hostname / IP: `chocoadmin`
|
- Forward Hostname / IP: `chocoadmin`
|
||||||
- Forward Port: `3000`
|
- Forward Port: `3000`
|
||||||
- Websockets Support: enabled
|
- Websockets Support: 활성화
|
||||||
- SSL: enabled
|
- SSL: 활성화
|
||||||
- Force SSL: enabled
|
- Force SSL: 활성화
|
||||||
|
|
||||||
Production URL:
|
운영 URL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://chocoadmin.jinaju.com
|
https://chocoadmin.jinaju.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from the compose `env_file`.
|
Docker 빌드는 시크릿을 커밋하지 않고도 Next.js가 컴파일될 수 있도록, 빌드 시점 환경 변수를 플레이스홀더로만 사용합니다. 런타임 값은 compose의 `env_file`에서 읽습니다.
|
||||||
|
|
||||||
## 4. AWS EC2 Security Group
|
## 4. AWS EC2 보안 그룹
|
||||||
|
|
||||||
Allow MariaDB only from the Synology NAS public IP.
|
MariaDB는 Synology NAS 공인 IP에서만 접근하도록 허용합니다.
|
||||||
|
|
||||||
- Type: `MYSQL/Aurora`
|
- Type: `MYSQL/Aurora`
|
||||||
- Protocol: `TCP`
|
- Protocol: `TCP`
|
||||||
@@ -145,79 +145,79 @@ Allow MariaDB only from the Synology NAS public IP.
|
|||||||
- Source: `NAS_PUBLIC_IP/32`
|
- Source: `NAS_PUBLIC_IP/32`
|
||||||
- Description: `chocoadmin Synology NAS`
|
- Description: `chocoadmin Synology NAS`
|
||||||
|
|
||||||
Do not open `3306` to `0.0.0.0/0`.
|
`3306`을 `0.0.0.0/0`으로 열지 마세요.
|
||||||
|
|
||||||
## 5. Read-Only Rehearsal
|
## 5. 읽기 전용 리허설
|
||||||
|
|
||||||
Before enabling approval/rejection operations against production DB:
|
운영 DB에 대해 승인/거절 작업을 활성화하기 전:
|
||||||
|
|
||||||
1. Create or use a DB account with read-only permissions.
|
1. 읽기 전용 권한을 가진 DB 계정을 생성하거나 사용합니다.
|
||||||
2. Set `DATABASE_URL` in `.env.production` to that read-only account.
|
2. `.env.production`의 `DATABASE_URL`을 해당 읽기 전용 계정으로 설정합니다.
|
||||||
3. Start the container.
|
3. 컨테이너를 시작합니다.
|
||||||
4. Verify login, maestro list, extension request list, and upgrade request list.
|
4. 로그인, 마에스트로 목록, 연장 신청 목록, 업그레이드 신청 목록을 확인합니다.
|
||||||
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
|
5. 읽기 화면이 정상 동작한 뒤에만 쓰기 가능한 운영 chocoadmin DB 계정으로 전환합니다.
|
||||||
|
|
||||||
## 6. Smoke Checks
|
## 6. 스모크 체크
|
||||||
|
|
||||||
After each deployment:
|
배포 후 매번:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.stage.yml ps # stage
|
docker compose -f docker-compose.stage.yml ps # 스테이지
|
||||||
docker compose ps # production
|
docker compose ps # 운영
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify these routes in the browser:
|
브라우저에서 다음 경로들을 확인합니다:
|
||||||
|
|
||||||
- `/login`
|
- `/login`
|
||||||
- `/maestros`
|
- `/maestros`
|
||||||
- `/extension-requests`
|
- `/extension-requests`
|
||||||
- `/upgrade-requests`
|
- `/upgrade-requests`
|
||||||
|
|
||||||
## 7. Troubleshooting
|
## 7. 트러블슈팅
|
||||||
|
|
||||||
### ERR_TOO_MANY_REDIRECTS after login
|
### 로그인 후 ERR_TOO_MANY_REDIRECTS
|
||||||
|
|
||||||
Middleware redirects to `/login`, login page redirects back to `/` — infinite loop. Work through this checklist in order:
|
미들웨어가 `/login`으로 리다이렉트하고, 로그인 페이지가 다시 `/`로 리다이렉트 — 무한 루프입니다. 다음 체크리스트를 순서대로 진행하세요:
|
||||||
|
|
||||||
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
|
1. **`AUTH_URL` / `NEXTAUTH_URL` 확인** — 스킴을 포함해 공개 HTTPS URL과 정확히 일치해야 합니다.
|
||||||
2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request.
|
2. **NPM 스킴 확인** — NPM은 컨테이너로 `https`가 아닌 `http` 스킴으로 포워드해야 합니다. 앱은 들어오는 요청이 아니라 `AUTH_URL`로 HTTPS를 감지합니다.
|
||||||
3. **Check cookie name consistency** — `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `chocoadmin-${APP_ENV}.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session.
|
3. **쿠키 이름 일관성 확인** — `auth.ts`(`cookies.sessionToken.name`)와 `proxy.ts`(`getToken({ cookieName })`) 모두 동일한 `chocoadmin-${APP_ENV}.session-token` 값을 사용해야 합니다. `proxy.ts`가 기본 이름을 사용하는데 `auth.ts`는 커스텀 이름을 쓰면, 미들웨어는 세션을 절대 찾지 못합니다.
|
||||||
4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP.
|
4. **`__Secure-` 접두사 확인** — 붙이지 마세요. 미들웨어는 Node.js 런타임에서 실행되고 Nginx로부터 HTTP를 받습니다. `__Secure-` 쿠키는 HTTP 상에서 조용히 거부됩니다.
|
||||||
5. **Clear browser cookies** for the domain, then test in a private window.
|
5. 해당 도메인의 브라우저 **쿠키를 삭제**하고 시크릿 창에서 테스트합니다.
|
||||||
6. **Recreate the container** after changing `.env.*`.
|
6. `.env.*`를 변경한 뒤에는 컨테이너를 **재생성**합니다.
|
||||||
|
|
||||||
### Production and stage traffic mixing
|
### 운영과 스테이지 트래픽이 섞임
|
||||||
|
|
||||||
Symptom: logging into `chocoadmin.jinaju.com` shows the stage UI, or requests hit both containers interchangeably.
|
증상: `chocoadmin.jinaju.com`에 로그인했는데 스테이지 UI가 표시되거나, 요청이 두 컨테이너를 번갈아가며 도달함.
|
||||||
|
|
||||||
Cause: Docker registers each `services:` key as a DNS alias in `proxy-network`. If stage and production share the same service name, NPM's upstream resolves to both containers via round-robin.
|
원인: Docker는 각 `services:` 키를 `proxy-network`의 DNS 별칭으로 등록합니다. 스테이지와 운영이 같은 서비스 이름을 공유하면, NPM의 업스트림이 라운드 로빈으로 두 컨테이너 모두로 매핑됩니다.
|
||||||
|
|
||||||
Check the network:
|
네트워크 확인:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
|
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: `chocoadmin` and `chocoadmin-stage` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias.
|
기대 결과: `chocoadmin`과 `chocoadmin-stage`가 서로 다른 IP로 나타남. `chocoadmin`이 두 번 나타나면, 오래된 컨테이너가 그 별칭을 사용 중입니다.
|
||||||
|
|
||||||
Fix: ensure `docker-compose.stage.yml` has `services: chocoadmin-stage:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM.
|
수정: `docker-compose.stage.yml`이 `services: chocoadmin-stage:`(`chocoadmin` 아님)를 사용하는지 확인하고, 오래된 컨테이너를 정리하기 위해 `--remove-orphans`로 스테이지를 한 번 재배포한 뒤 NPM을 재로드합니다.
|
||||||
|
|
||||||
### Failed to find Server Action after redeployment
|
### 재배포 후 Failed to find Server Action
|
||||||
|
|
||||||
Symptom: clicking a button (e.g. logout) returns a 404 or `Failed to find Server Action` error after deploying a new build.
|
증상: 새 빌드를 배포한 뒤 버튼(예: 로그아웃)을 클릭하면 404 또는 `Failed to find Server Action` 에러가 발생함.
|
||||||
|
|
||||||
Cause: the action was defined as an inline `"use server"` closure. Closures get a new action ID on every build. The browser cached the old ID.
|
원인: 액션이 인라인 `"use server"` 클로저로 정의되었습니다. 클로저는 빌드마다 새로운 액션 ID를 얻습니다. 브라우저는 오래된 ID를 캐싱합니다.
|
||||||
|
|
||||||
Fix: force-reload the page (`Cmd+Shift+R` / `Ctrl+Shift+R`) to discard the cached page with stale action IDs. If the problem recurs after every deployment, the action must be extracted to a module-level named export in a separate `actions.ts` file.
|
수정: 강제 새로고침(`Cmd+Shift+R` / `Ctrl+Shift+R`)으로 오래된 액션 ID가 담긴 캐시 페이지를 폐기합니다. 배포마다 문제가 반복된다면, 액션을 별도의 `actions.ts` 파일에서 모듈 레벨 named export로 추출해야 합니다.
|
||||||
|
|
||||||
### Container starts but immediately exits
|
### 컨테이너가 시작하자마자 종료됨
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs chocoadmin
|
docker compose logs chocoadmin
|
||||||
```
|
```
|
||||||
|
|
||||||
Common causes:
|
일반적인 원인:
|
||||||
|
|
||||||
- Missing `DATABASE_URL` or malformed connection string.
|
- 누락되었거나 형식이 잘못된 `DATABASE_URL` 연결 문자열.
|
||||||
- `AUTH_SECRET` not set — NextAuth throws on startup.
|
- `AUTH_SECRET`이 설정되지 않음 — NextAuth가 시작 시점에 예외를 던집니다.
|
||||||
- Port already allocated — check if another service uses host port 3000. Both compose files use `expose` (not `ports`) for port 3000, so this should not happen unless the compose file was modified.
|
- 포트가 이미 할당됨 — 다른 서비스가 호스트 포트 3000을 사용 중인지 확인하세요. 두 compose 파일 모두 포트 3000에 `ports`가 아닌 `expose`를 사용하므로, compose 파일이 수정되지 않은 한 이런 문제는 발생하지 않아야 합니다.
|
||||||
|
|||||||
+28
-6
@@ -148,10 +148,20 @@ export async function approveUpgradeRequest(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
|
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
|
||||||
|
const isRequestedPaid = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(
|
||||||
|
request!.RequestedAccountType
|
||||||
|
);
|
||||||
|
const isCurrentPaid = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(
|
||||||
|
request!.maestro.AccountType
|
||||||
|
);
|
||||||
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||||
const isStillValidUpgrade = isApprovalTrial
|
const isStillValidUpgrade =
|
||||||
? request!.RequestedAccountType >= request!.maestro.AccountType
|
isRequestedPaid &&
|
||||||
: request!.RequestedAccountType > request!.maestro.AccountType;
|
(!isCurrentPaid
|
||||||
|
? true
|
||||||
|
: isApprovalTrial
|
||||||
|
? request!.RequestedAccountType >= request!.maestro.AccountType
|
||||||
|
: request!.RequestedAccountType > request!.maestro.AccountType);
|
||||||
if (!isStillValidUpgrade) {
|
if (!isStillValidUpgrade) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
|
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
|
||||||
@@ -214,10 +224,22 @@ export async function createUpgradeRequest(
|
|||||||
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRequestedPaid = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(
|
||||||
|
requestedAccountType
|
||||||
|
);
|
||||||
|
if (!isRequestedPaid) {
|
||||||
|
throw new ApiError("업그레이드 대상은 유료 요금제여야 합니다.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentPaid = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(
|
||||||
|
maestro.AccountType
|
||||||
|
);
|
||||||
const isTrial = maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
const isTrial = maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||||
const isValidUpgrade = isTrial
|
const isValidUpgrade = !isCurrentPaid
|
||||||
? requestedAccountType >= maestro.AccountType
|
? true
|
||||||
: requestedAccountType > maestro.AccountType;
|
: isTrial
|
||||||
|
? requestedAccountType >= maestro.AccountType
|
||||||
|
: requestedAccountType > maestro.AccountType;
|
||||||
|
|
||||||
if (!isValidUpgrade) {
|
if (!isValidUpgrade) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { config } from "dotenv";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
config({ path: ".env.local" });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
const { createUpgradeRequest, approveUpgradeRequest } = await import(
|
||||||
|
"@/lib/upgrade-requests"
|
||||||
|
);
|
||||||
|
const { ACTIVATE_STATUSES, ACCOUNT_TYPES, REQUEST_STATUSES, PAID_ACCOUNT_TYPE_VALUES } = await import(
|
||||||
|
"@/lib/constants"
|
||||||
|
);
|
||||||
|
const { ApiError } = await import("@/lib/errors");
|
||||||
|
|
||||||
|
// Mirrors the zod schema in app/api/maestros/[id]/upgrade-requests/route.ts —
|
||||||
|
// this is what the [업그레이드] button's POST body must pass through.
|
||||||
|
const postBodySchema = z.object({
|
||||||
|
requestedAccountType: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v)),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Scenario = {
|
||||||
|
label: string;
|
||||||
|
initialActivateStatus: number;
|
||||||
|
initialAccountType: number;
|
||||||
|
requestedAccountType: number;
|
||||||
|
createExpectsError?: number;
|
||||||
|
approveExpectsError?: number;
|
||||||
|
directInsertRequest?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scenarios: Scenario[] = [
|
||||||
|
{
|
||||||
|
label: "TRIAL status + paid AccountType(BASIC_20) → PRO_100 (higher tier)",
|
||||||
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
||||||
|
initialAccountType: ACCOUNT_TYPES.BASIC_20,
|
||||||
|
requestedAccountType: ACCOUNT_TYPES.PRO_100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "TRIAL status + paid AccountType(STANDARD_50) → STANDARD_50 (same-tier conversion)",
|
||||||
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
||||||
|
initialAccountType: ACCOUNT_TYPES.STANDARD_50,
|
||||||
|
requestedAccountType: ACCOUNT_TYPES.STANDARD_50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "TRIAL status + trial AccountType(MAESTRO_TRIAL=101) → BASIC_20 (via admin create)",
|
||||||
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
||||||
|
initialAccountType: ACCOUNT_TYPES.MAESTRO_TRIAL,
|
||||||
|
requestedAccountType: ACCOUNT_TYPES.BASIC_20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "TRIAL status + trial AccountType(STUDENT_TRIAL=100) → SCHOOL_1000 (direct-insert bypass, mimicking chocomae flow)",
|
||||||
|
initialActivateStatus: ACTIVATE_STATUSES.TRIAL,
|
||||||
|
initialAccountType: ACCOUNT_TYPES.STUDENT_TRIAL,
|
||||||
|
requestedAccountType: ACCOUNT_TYPES.SCHOOL_1000,
|
||||||
|
directInsertRequest: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const testName = `__TEST_TRIAL_UPG_${stamp}__`.slice(0, 50);
|
||||||
|
const initialAvailable = new Date(2000, 0, 1);
|
||||||
|
let maestroID: number | null = null;
|
||||||
|
|
||||||
|
console.log(`\n=== ${scenario.label} ===`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await db.maestro.create({
|
||||||
|
data: {
|
||||||
|
Name: testName,
|
||||||
|
Password: "",
|
||||||
|
Email: `${stamp}@test.invalid`.slice(0, 50),
|
||||||
|
AccountType: scenario.initialAccountType,
|
||||||
|
ActivateStatus: scenario.initialActivateStatus,
|
||||||
|
AvailableActivateDateTime: initialAvailable,
|
||||||
|
PlayerCount: 0,
|
||||||
|
AcceptClausesDateTime: new Date(),
|
||||||
|
AllowEditEnterCode: 0,
|
||||||
|
},
|
||||||
|
select: { MaestroID: true },
|
||||||
|
});
|
||||||
|
maestroID = created.MaestroID;
|
||||||
|
console.log(` seeded maestro MaestroID=${maestroID}`);
|
||||||
|
|
||||||
|
let maestroUpgradeID: number | null = null;
|
||||||
|
|
||||||
|
// Step 1: create request (either via admin API or direct insert)
|
||||||
|
if (scenario.directInsertRequest) {
|
||||||
|
const inserted = await db.maestro_upgrade.create({
|
||||||
|
data: {
|
||||||
|
MaestroID: maestroID,
|
||||||
|
RegisteredActivateStatus: scenario.initialActivateStatus,
|
||||||
|
RegisteredAccountType: scenario.initialAccountType,
|
||||||
|
RequestedAccountType: scenario.requestedAccountType,
|
||||||
|
RequestedDateTime: new Date(),
|
||||||
|
Status: REQUEST_STATUSES.REQUESTED,
|
||||||
|
},
|
||||||
|
select: { MaestroUpgradeID: true },
|
||||||
|
});
|
||||||
|
maestroUpgradeID = inserted.MaestroUpgradeID;
|
||||||
|
console.log(` direct-inserted upgrade request maestroUpgradeID=${maestroUpgradeID}`);
|
||||||
|
} else {
|
||||||
|
// Simulate the [업그레이드] button POST body path: zod-validate first.
|
||||||
|
const parsed = postBodySchema.safeParse({
|
||||||
|
requestedAccountType: scenario.requestedAccountType,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
` zod schema on { requestedAccountType: ${scenario.requestedAccountType} } → ${parsed.success ? "accepted" : "rejected"}`
|
||||||
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.log(
|
||||||
|
` FAIL API zod schema rejected paid-tier target (would produce 400 from API layer)`
|
||||||
|
);
|
||||||
|
failures += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await createUpgradeRequest(
|
||||||
|
maestroID,
|
||||||
|
parsed.data.requestedAccountType
|
||||||
|
);
|
||||||
|
if (scenario.createExpectsError !== undefined) {
|
||||||
|
console.log(` FAIL createUpgradeRequest expected ApiError(${scenario.createExpectsError}), succeeded`);
|
||||||
|
failures += 1;
|
||||||
|
} else {
|
||||||
|
maestroUpgradeID = result.maestroUpgradeID;
|
||||||
|
console.log(` createUpgradeRequest → maestroUpgradeID=${maestroUpgradeID}, status=${result.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
scenario.createExpectsError !== undefined &&
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.statusCode === scenario.createExpectsError
|
||||||
|
) {
|
||||||
|
console.log(` PASS createUpgradeRequest correctly threw ApiError(${error.statusCode}): ${error.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maestroUpgradeID === null) continue;
|
||||||
|
|
||||||
|
// Step 2: approve
|
||||||
|
try {
|
||||||
|
const approveResult = await approveUpgradeRequest(maestroUpgradeID);
|
||||||
|
if (scenario.approveExpectsError !== undefined) {
|
||||||
|
console.log(` FAIL approveUpgradeRequest expected ApiError(${scenario.approveExpectsError}), succeeded`);
|
||||||
|
failures += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(` approveUpgradeRequest → availableActivateDateTime=${approveResult.availableActivateDateTime}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
scenario.approveExpectsError !== undefined &&
|
||||||
|
error instanceof ApiError &&
|
||||||
|
error.statusCode === scenario.approveExpectsError
|
||||||
|
) {
|
||||||
|
console.log(` PASS approveUpgradeRequest correctly threw ApiError(${error.statusCode}): ${error.message}`);
|
||||||
|
|
||||||
|
// Verify rollback: request row should NOT be APPLIED, and maestro state should be unchanged
|
||||||
|
const stillRequestedRow = await db.maestro_upgrade.findUniqueOrThrow({
|
||||||
|
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||||
|
select: { Status: true },
|
||||||
|
});
|
||||||
|
const stillTrialMaestro = await db.maestro.findUniqueOrThrow({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: { ActivateStatus: true, AccountType: true },
|
||||||
|
});
|
||||||
|
const rollbackOK =
|
||||||
|
stillRequestedRow.Status === REQUEST_STATUSES.REQUESTED &&
|
||||||
|
stillTrialMaestro.ActivateStatus === scenario.initialActivateStatus &&
|
||||||
|
stillTrialMaestro.AccountType === scenario.initialAccountType;
|
||||||
|
console.log(` ${rollbackOK ? "PASS" : "FAIL"} transaction rollback preserved state`);
|
||||||
|
if (!rollbackOK) failures += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: verify post-approval state
|
||||||
|
const afterApprove = await db.maestro.findUniqueOrThrow({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: {
|
||||||
|
ActivateStatus: true,
|
||||||
|
AvailableActivateDateTime: true,
|
||||||
|
AccountType: true,
|
||||||
|
PlayerCount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const requestRow = await db.maestro_upgrade.findUniqueOrThrow({
|
||||||
|
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||||
|
select: { Status: true },
|
||||||
|
});
|
||||||
|
const logRows = await db.maestro_log.findMany({
|
||||||
|
where: { MaestroID: maestroID },
|
||||||
|
select: { Type: true },
|
||||||
|
orderBy: { MaestroLogID: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowPlus1yr = new Date();
|
||||||
|
nowPlus1yr.setFullYear(nowPlus1yr.getFullYear() + 1);
|
||||||
|
|
||||||
|
const checks: Array<[string, boolean, string]> = [
|
||||||
|
[
|
||||||
|
"maestro.ActivateStatus becomes ACTIVE",
|
||||||
|
afterApprove.ActivateStatus === ACTIVATE_STATUSES.ACTIVE,
|
||||||
|
`got ${afterApprove.ActivateStatus}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"maestro.AccountType becomes RequestedAccountType",
|
||||||
|
afterApprove.AccountType === scenario.requestedAccountType,
|
||||||
|
`got ${afterApprove.AccountType}, expected ${scenario.requestedAccountType}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"maestro.PlayerCount unchanged (upgrade should NOT touch PlayerCount)",
|
||||||
|
afterApprove.PlayerCount === 0,
|
||||||
|
`got ${afterApprove.PlayerCount}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"AvailableActivateDateTime ≈ now + 1 year",
|
||||||
|
Math.abs(afterApprove.AvailableActivateDateTime.getTime() - nowPlus1yr.getTime()) < 60_000,
|
||||||
|
`delta=${afterApprove.AvailableActivateDateTime.getTime() - nowPlus1yr.getTime()}ms`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"upgrade request Status becomes APPLIED",
|
||||||
|
requestRow.Status === REQUEST_STATUSES.APPLIED,
|
||||||
|
`got ${requestRow.Status}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"logs include upgrade_maestro",
|
||||||
|
logRows.some((l) => l.Type === "upgrade_maestro"),
|
||||||
|
JSON.stringify(logRows.map((l) => l.Type)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, passed, detail] of checks) {
|
||||||
|
console.log(` ${passed ? "PASS" : "FAIL"} ${name} (${detail})`);
|
||||||
|
if (!passed) failures += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failures += 1;
|
||||||
|
console.error(` ERROR:`, error);
|
||||||
|
} finally {
|
||||||
|
if (maestroID !== null) {
|
||||||
|
await db.maestro_upgrade.deleteMany({ where: { MaestroID: maestroID } });
|
||||||
|
await db.maestro_log.deleteMany({ where: { MaestroID: maestroID } });
|
||||||
|
await db.maestro.delete({ where: { MaestroID: maestroID } });
|
||||||
|
console.log(` cleanup: removed rows for MaestroID=${maestroID}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$disconnect();
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`\n${failures} check(s) failed.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`\nAll checks passed.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Test run failed");
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user