Compare commits

...

3 Commits

9 changed files with 663 additions and 194 deletions
+97 -97
View File
@@ -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`) 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). - **연장 신청**(`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 08: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. - 페이즈 08: 로그인, 마에스트로 목록/상세, 연장 신청, 업그레이드 신청, 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; rejects trial account types. - `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,20 +5,17 @@ import { useRouter } from "next/navigation";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { 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";
type ExtensionRequestsSectionProps = { type ExtensionRequestsSectionProps = {
maestroID: number; maestroID: number;
accountType: number;
totalCount: number; totalCount: number;
extensionRequests: MaestroDetail["extensionRequests"]; extensionRequests: MaestroDetail["extensionRequests"];
}; };
export function ExtensionRequestsSection({ export function ExtensionRequestsSection({
maestroID, maestroID,
accountType,
totalCount, totalCount,
extensionRequests, extensionRequests,
}: ExtensionRequestsSectionProps) { }: ExtensionRequestsSectionProps) {
@@ -27,7 +24,6 @@ export function ExtensionRequestsSection({
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isPaidAccount = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
const isDisabled = isCreating || isPending; const isDisabled = isCreating || isPending;
async function handleCreate() { async function handleCreate() {
@@ -73,7 +69,7 @@ export function ExtensionRequestsSection({
</div> </div>
<div className="flex flex-col items-start gap-1 sm:items-end"> <div className="flex flex-col items-start gap-1 sm:items-end">
<Button <Button
disabled={isDisabled || !isPaidAccount} disabled={isDisabled}
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
size="sm" size="sm"
type="button" type="button"
@@ -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}
> >
@@ -94,7 +94,6 @@ export default async function MaestroDetailPage({
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<ExtensionRequestsSection <ExtensionRequestsSection
accountType={maestro.accountType}
extensionRequests={detail.extensionRequests} extensionRequests={detail.extensionRequests}
maestroID={maestroID} maestroID={maestroID}
totalCount={detail.counts.extensionRequests} totalCount={detail.counts.extensionRequests}
+73 -73
View File
@@ -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 파일이 수정되지 않은 한 이런 문제는 발생하지 않아야 합니다.
+17 -9
View File
@@ -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 { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants"; import { ACTIVATE_STATUSES, 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,
@@ -145,8 +145,14 @@ export async function approveExtensionRequest(
}, },
}); });
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) { if (
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409); request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
) {
throw new ApiError(
"활성화 또는 체험 계정만 연장 승인이 가능합니다.",
409
);
} }
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
@@ -200,12 +206,14 @@ export async function createExtensionRequest(
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
} }
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) { if (
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400); maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
} maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
) {
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) { throw new ApiError(
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400); "활성화 또는 체험 계정만 연장 신청이 가능합니다.",
400
);
} }
const request = await tx.maestro_extension.create({ const request = await tx.maestro_extension.create({
+28 -6
View File
@@ -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(
+159
View File
@@ -0,0 +1,159 @@
import { config } from "dotenv";
config({ path: ".env.local" });
async function main() {
const { db } = await import("@/lib/db");
const { createExtensionRequest, approveExtensionRequest } = await import(
"@/lib/extension-requests"
);
const { ACTIVATE_STATUSES, ACCOUNT_TYPES, REQUEST_STATUSES } = await import(
"@/lib/constants"
);
const scenarios = [
{
label: "TRIAL status + paid tier (BASIC_20)",
accountType: ACCOUNT_TYPES.BASIC_20,
},
{
label: "TRIAL status + trial tier (STUDENT_TRIAL)",
accountType: ACCOUNT_TYPES.STUDENT_TRIAL,
},
];
let failures = 0;
for (const scenario of scenarios) {
const stamp = Date.now();
const testName = `__TEST_TRIAL_EXT_${stamp}__`.slice(0, 50);
const initialAvailable = new Date(2000, 0, 1);
let maestroID: number | null = null;
console.log(`\n=== Scenario: ${scenario.label} ===`);
try {
const created = await db.maestro.create({
data: {
Name: testName,
Password: "",
Email: `${stamp}@test.invalid`.slice(0, 50),
AccountType: scenario.accountType,
ActivateStatus: ACTIVATE_STATUSES.TRIAL,
AvailableActivateDateTime: initialAvailable,
PlayerCount: 0,
AcceptClausesDateTime: new Date(),
AllowEditEnterCode: 0,
},
select: { MaestroID: true },
});
maestroID = created.MaestroID;
console.log(` seeded maestro MaestroID=${maestroID}`);
const requestResult = await createExtensionRequest(maestroID);
console.log(
` createExtensionRequest → maestroExtensionID=${requestResult.maestroExtensionID}, status=${requestResult.status}`
);
if (requestResult.status !== REQUEST_STATUSES.REQUESTED) {
throw new Error(
`expected new request status=${REQUEST_STATUSES.REQUESTED}, got ${requestResult.status}`
);
}
const approveResult = await approveExtensionRequest(
requestResult.maestroExtensionID
);
console.log(
` approveExtensionRequest → availableActivateDateTime=${approveResult.availableActivateDateTime}`
);
const afterApprove = await db.maestro.findUniqueOrThrow({
where: { MaestroID: maestroID },
select: {
ActivateStatus: true,
AvailableActivateDateTime: true,
AccountType: true,
},
});
const requestRow = await db.maestro_extension.findUniqueOrThrow({
where: { MaestroExtensionID: requestResult.maestroExtensionID },
select: { Status: true, AccountType: true },
});
const logRows = await db.maestro_log.findMany({
where: { MaestroID: maestroID },
select: { Type: true },
orderBy: { MaestroLogID: "asc" },
});
const today = new Date();
today.setHours(0, 0, 0, 0);
const expectedYear = today.getFullYear() + 1;
const actualAvailable = afterApprove.AvailableActivateDateTime;
const actualYear = actualAvailable.getFullYear();
const checks: Array<[string, boolean, string]> = [
[
"maestro.ActivateStatus becomes ACTIVE",
afterApprove.ActivateStatus === ACTIVATE_STATUSES.ACTIVE,
`got ${afterApprove.ActivateStatus}`,
],
[
"maestro.AccountType preserved",
afterApprove.AccountType === scenario.accountType,
`got ${afterApprove.AccountType}`,
],
[
"AvailableActivateDateTime = today +1 year (initial was in the past)",
actualYear === expectedYear,
`got year=${actualYear}, expected ${expectedYear}`,
],
[
"extension request Status becomes APPLIED",
requestRow.Status === REQUEST_STATUSES.APPLIED,
`got ${requestRow.Status}`,
],
[
"logs include request_extension_maestro",
logRows.some((l) => l.Type === "request_extension_maestro"),
JSON.stringify(logRows.map((l) => l.Type)),
],
[
"logs include extension_maestro",
logRows.some((l) => l.Type === "extension_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_extension.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;
});
+274
View File
@@ -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;
});