Compare commits

..

59 Commits

Author SHA1 Message Date
jisangs 00d7659812 Cookie 세션 토큰명도 변경 2026-07-09 10:56:13 +09:00
jisangs e5c4d325bf 스테이지용 변수 수정: stage-chocoadmin -> chocoadmin-stage 2026-07-09 10:54:06 +09:00
jisangs fb0861f211 docker-compose.yml 내용 수정: env_file에 환경 변수 설정 제거
- Production에서 .env.production 파일을 적용
2026-07-09 09:42:15 +09:00
jisangs a6fcff98b4 README.md 파일에서 중요하지 않은 내용 삭제 2026-07-07 10:27:28 +09:00
jisangs bbadaaea36 반응형 웹 디자인 적용 작업을 기존 문서에 반영 2026-07-07 10:22:29 +09:00
jisangs bd921528f4 반응형 웹 디자인 적용 2026-07-07 10:06:13 +09:00
jisangs e6882b158a 홈페이지 파비콘 교체 2026-07-06 15:21:19 +09:00
jisangs c7d55b5463 개발 내용 README.md 파일에 반영 2026-07-05 18:34:02 +09:00
jisangs 5463395b04 작업 내용 반영 2026-07-05 14:16:35 +09:00
jisangs b9219da6a4 운영 서버 배포 스크립트 에러 수정 2026-07-05 14:07:21 +09:00
jisangs a213699c02 배포 스크립트 추가 2026-07-05 13:56:15 +09:00
jisangs ff67e5cf5a 마에스트로 상세화면에 암호 초기화 버튼 추가 (암호 초기화: 123456) 2026-07-05 13:48:17 +09:00
jisangs a15543859e 작업 내용 반영 2026-07-05 13:36:18 +09:00
jisangs b9aa6e07a9 운영 서버 query문 로그 출력 내용 수정 2026-07-05 13:12:49 +09:00
jisangs c635d64141 chocoadmin, stage-chocoadmin 서비스를 Synology 도커 배포 환경 작업 내용을 문서에 반영 2026-07-05 13:06:50 +09:00
jisangs fcab1c83b7 fix: stage 서비스 이름을 stage-chocoadmin으로 변경 - production과 네트워크 alias 충돌 해소 2026-07-05 12:41:07 +09:00
jisangs 65e9b0c5f3 로그아웃을 모듈 수준 서버 액션으로 변환 (빌드 간 액션 ID 안정화) 2026-07-05 12:24:48 +09:00
jisangs c2b1b6169a 로그아웃 리다이렉트를 상대경로로 직접 제어 2026-07-05 11:54:59 +09:00
jisangs df1cdd3304 로그인 후 리다이렉트를 검증된 상대경로로 고정, NextAuth 계산 URL은 로그로 기록 2026-07-05 11:46:21 +09:00
jisangs 0330a99f5f proxy 미들웨어에 커스텀 세션 쿠키 이름 전달, 환경별 쿠키 이름 분리 2026-07-05 11:06:04 +09:00
jisangs b9db9ff3e7 callbackUrl 외부 도메인 리다이렉트 차단, 환경별 세션 쿠키 이름 분리 2026-07-05 00:49:50 +09:00
jisangs 080640aa50 세션 쿠키 이름에서 __Secure- prefix 제거 (Nginx 역방향 프록시 환경 대응) 2026-07-05 00:27:45 +09:00
jisangs c6976d7648 환경별 고유 세션 쿠키 이름 설정으로 stage/production 쿠키 충돌 방지 수정 2026-07-05 00:17:51 +09:00
jisangs 802ddb0fa7 docker-compose 파일에 주석 내용 추가 2026-07-04 23:07:09 +09:00
jisangs 8017d23717 production 설정 수정 - port 중복, 로그 설정 2026-07-04 18:16:06 +09:00
jisangs 8269eee996 로그 파일에서 query 결과가 잘려보이는 오류 수정 (3), 로그 시간 오류 수정 2026-07-04 17:58:58 +09:00
jisangs 7e2f629681 로그 파일에서 query 결과가 잘려보이는 오류 수정 (2) 2026-07-04 17:48:33 +09:00
jisangs f6e0e845f4 로그 파일에서 query 결과가 잘려보이는 오류 수정 2026-07-04 17:13:50 +09:00
jisangs a0d371837c 운영 배포 환경 설정 내용 추가 2026-07-04 16:36:01 +09:00
jisangs 56f32f8045 마에스트로 상세 화면의 처리 로그 표에 페이지네이션 추가 2026-07-04 16:25:34 +09:00
jisangs 0a9aee58e0 전체 코드, 구조 검토 - P2 코드 수정 진행 2026-07-04 15:59:50 +09:00
jisangs 2231ac4bfe 전체 코드, 구조 검토 - P1-6 코드 수정 진행 2026-07-04 15:40:33 +09:00
jisangs 41a246ad7b 전체 코드, 구조 검토 - P1 코드 수정 진행 2026-07-04 15:32:19 +09:00
jisangs 3c5848cdc5 전체 코드, 구조 검토 - P0 코드 수정 진행 2026-07-04 10:13:12 +09:00
jisangs 854a3e2bfb 코드 검토 및 문서 반영 2026-07-02 22:13:38 +09:00
jisangs 2d9b8d0624 마에스트로 상세 화면에 [업그레이드] 버튼 추가 2026-07-02 21:51:09 +09:00
jisangs 7669e9d0de 마에스트로 상세 화면에 [연장] 신청 버튼 추가 2026-07-02 21:50:56 +09:00
jisangs b2483a1b77 Phase 11. 이메일 발송 환경 및 자동 발송 2026-07-02 18:09:42 +09:00
jisangs 5f9bb39ce1 Phase 10. 마에스트로 정보 수정 및 학생 목록 페이지네이션 적용 2026-06-29 17:20:24 +09:00
jisangs f384388c67 웹 로그, DB 쿼리문과 결과 로그 출력 2026-06-22 22:34:56 +09:00
jisangs bcd0ef2af3 추가 작업 계획 2026-06-14 23:17:27 +09:00
jisangs b28e88db66 초코마에 이메일 기능 조사, 정리 2026-06-14 23:04:42 +09:00
jisangs 89c0e8be84 업그레이드 화면 신청 일시 양식 수정 2026-06-14 22:54:22 +09:00
jisangs a8ee01ece9 추가 작업 등록 - Phase 9. 실행 로그 및 DB 쿼리 로그 2026-06-14 22:53:58 +09:00
jisangs 04b2a925bb Phase 8. 검증 및 안정화 작업 진행 2026-06-11 15:35:31 +09:00
jisangs 0481ffb1a8 stage-chocoadmin 내용을 deployment.md 문서에 반영 2026-06-11 14:55:37 +09:00
jisangs 0a7361402d cookie를 읽을 때, https:// 이면 secure cookie를 읽도록 변경 2026-06-11 14:28:03 +09:00
jisangs 39c5ddb55c AUTH_URL, NEXTAUTH_URL에서 포트 번호 제거 2026-06-11 14:10:57 +09:00
jisangs 920176d57d docker 생성시 proxy-network 사용하도록 설정 2026-06-11 11:40:53 +09:00
jisangs 1f6da75907 서비스 포트 변경: 3001 -> 3000 2026-06-11 11:33:48 +09:00
jisangs d82f39c2f6 프로젝트명 변경: choco-admin -> chocoadmin 2026-06-11 11:22:01 +09:00
jisangs 976fe42a2a Phase 7. 배포 환경 구성 작업 진행 2026-06-11 10:58:27 +09:00
jisangs 605f6bcdf0 Phase 6. 업그레이드 신청 관리 구현 2026-06-11 09:06:26 +09:00
jisangs 5fa387fa96 Phase 5. 연장 신청 관리 구현 2026-06-11 08:56:36 +09:00
jisangs fbe4caa9e1 Phase 4. 마에스트로 목록 화면 구현 2026-06-11 08:28:03 +09:00
jisangs dbf9b3e180 Phase 3. 인증 및 공통 레이아웃 작업 2026-06-09 23:37:33 +09:00
jisangs a93a6fa8e1 Phase 1. 프로젝트 초기화 진행 2026-06-09 22:40:10 +09:00
jisangs af5b46baf3 Phase 0. 기존 관리자 코드 조사 실행 2026-06-09 22:19:46 +09:00
jisangs c6a5418d25 plan 생성 2026-06-09 18:37:32 +09:00
92 changed files with 18151 additions and 22 deletions
+21
View File
@@ -0,0 +1,21 @@
.env
.env.*
!.env.example
.git
.gitignore
.next
.pnpm-store
.turbo
.DS_Store
.idea
.claude
coverage
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
docker-compose.yml
README.md
docs
+58
View File
@@ -0,0 +1,58 @@
# Local development
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
AUTH_SECRET="replace-with-local-secret"
NEXTAUTH_SECRET="replace-with-local-secret"
AUTH_URL="http://localhost:3000"
NEXTAUTH_URL="http://localhost:3000"
# Logging: APP_ENV=local|stage|production / LOG_LEVEL=debug|info|warn|error
APP_ENV=local
LOG_LEVEL=debug
# Mail (MAIL_SEND_ENABLED=false 로 발송 비활성화, true 로 실제 발송)
MAIL_SMTP_HOST=smtp.gmail.com
MAIL_SMTP_PORT=465
MAIL_SMTP_SECURE=true
MAIL_SMTP_USER=support+chocomae@jinaju.com
MAIL_SMTP_PASSWORD=<gmail_app_password>
MAIL_FROM_NAME=초코마에
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
MAIL_SEND_ENABLED=false
MAIL_OVERRIDE_TO=jisangs@naver.com
# Production example (.env.production)
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
# AUTH_SECRET="replace-with-production-secret"
# NEXTAUTH_SECRET="replace-with-production-secret"
# AUTH_URL="https://NAS_HOST_OR_DOMAIN"
# NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN"
# APP_ENV=production
# LOG_LEVEL=info
# MAIL_SMTP_HOST=smtp.gmail.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_SECURE=true
# MAIL_SMTP_USER=support+chocomae@jinaju.com
# MAIL_SMTP_PASSWORD=<gmail_app_password>
# MAIL_FROM_NAME=초코마에
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
# MAIL_SEND_ENABLED=true
# MAIL_OVERRIDE_TO=
# Stage example (.env.stage)
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
# AUTH_SECRET="replace-with-stage-secret"
# NEXTAUTH_SECRET="replace-with-stage-secret"
# AUTH_URL="https://STAGE_HOST_OR_DOMAIN"
# NEXTAUTH_URL="https://STAGE_HOST_OR_DOMAIN"
# APP_ENV=stage
# LOG_LEVEL=debug
# MAIL_SMTP_HOST=smtp.gmail.com
# MAIL_SMTP_PORT=465
# MAIL_SMTP_SECURE=true
# MAIL_SMTP_USER=support+chocomae@jinaju.com
# MAIL_SMTP_PASSWORD=<gmail_app_password>
# MAIL_FROM_NAME=초코마에
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
# MAIL_SEND_ENABLED=false
# MAIL_OVERRIDE_TO=jisangs@naver.com
+42 -22
View File
@@ -1,25 +1,45 @@
### macOS
# Finder metadata
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
# Thumbnails
._*
# Custom folder icons
Icon
# Volume root files
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
### Local development tools
*.pem
.idea/
# Claude Code local settings
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/lib/generated/prisma
+8
View File
@@ -0,0 +1,8 @@
.next
node_modules
pnpm-lock.yaml
lib/generated/prisma
docs
AGENTS.md
CLAUDE.md
README.md
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
+127
View File
@@ -0,0 +1,127 @@
# This is NOT the Next.js you know
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.
---
# Project: 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**.
## Critical rules
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
- **Race-condition guard for approve/cancel/reject**: use a conditional `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })` as the first step inside a transaction. If `count === 0`, check existence to return 404 vs 409. Never use read-then-write (findUnique → check Status → update) — it allows double-processing under concurrent requests.
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
- **Send email only after a successful commit**, never inside the transaction.
- **Never use `include: { maestro: true }`** when querying `maestro_extension` or `maestro_upgrade`. Always use `select` with only the needed columns to avoid logging the maestro `Password` field.
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
- **Docker 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.
- **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`).
- **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.
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
- **Upgrade requests**: TRIAL accounts (`ActivateStatus = 1`) may request the **same tier** (trial-to-paid conversion, `requestedAccountType >= AccountType`). ACTIVE accounts must use a strictly higher tier (`>`). At approval time, this direction is re-verified against the maestro's **current** `AccountType` — if it became invalid (e.g., admin manually raised the tier), the approval throws 409 and rolls back.
- Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional.
## Authentication
- NextAuth.js v5 (`next-auth@5.0.0-beta.31`) with Credentials Provider.
- Config lives in `auth.ts` (project root), not inside `app/`.
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
- API routes must call `auth()` and return 401 if no session.
- Session `maxAge` is 8 hours — do not lengthen without review.
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
- 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.
- 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.
## Key files
| File | Purpose |
|---|---|
| `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) |
| `proxy.ts` | Middleware — redirects unauthenticated users to /login |
| `lib/db.ts` | Prisma Client singleton with SQL pretty-logging and result table logging |
| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` |
| `lib/errors.ts` | `ApiError` — unified error class with HTTP status code |
| `lib/api-handler.ts` | `withApiHandler` — wraps route handlers with auth, timing, and error logging |
| `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`); `PAID_ACCOUNT_TYPE_VALUES` (유료 계정 유형 배열 — 중복 정의 방지용 공용 상수) |
| `lib/maestros.ts` | Maestro DB queries |
| `lib/extension-requests.ts` | Extension request queries and approval logic |
| `lib/upgrade-requests.ts` | Upgrade request queries and approval logic |
| `prisma/schema.prisma` | Generated via `prisma db pull` — do not hand-edit model fields |
| `docs/business-rules.md` | Full reference for existing chocomae business logic |
| `docs/phase/phase9.md` | Log policy: levels, format, masking rules, Docker log operations |
## maestro_log conventions
Always record a log entry after a successful DB change. Use these `Type` values:
| Type | When |
|---|---|
| `extension_maestro` | Extension approved |
| `cancel_extension_maestro` | Extension cancelled (new type, not in original) |
| `upgrade_maestro` | Upgrade approved |
| `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) |
| `update_maestro_name` | Maestro name changed | `{prev} -> {new}` |
| `update_maestro_email` | Maestro email changed | `{prev} -> {new}` |
| `update_maestro_status` | ActivateStatus changed (admin, new) | `{prev} -> {new}` (numeric) |
| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) |
| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
| `reset_maestro_password` | Maestro password reset to 123456 by admin | `admin reset to 123456` |
`Remark` column is `char(100)` — keep values under 100 characters.
## Implemented phases (do not re-implement)
- Phase 08: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
- 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.
- Phase 10: Maestro info edit form (`PATCH /api/maestros/[id]`) + student list with server-side pagination (`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.
- Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page.
- `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]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
- Phase 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.
- `resetMaestroPassword(maestroID)` added to `lib/maestros.ts` — runs `UPDATE maestro SET Password = PASSWORD('123456')` inside a `$transaction` with a `reset_maestro_password` log entry.
- `app/api/maestros/[id]/reset-password/route.ts``POST` handler wrapped with `withApiHandler`.
- 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.
- Phase 13 (security/correctness fixes):
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
- P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency.
- P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade.
- P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time.
- P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries.
- P1-6: Extension/upgrade approval routes now `await` email send and include `emailSent: boolean` in response; table UI shows inline amber warning when `emailSent === false`.
- P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed.
- P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses).
- P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`).
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
- Phase 16: 반응형 웹 디자인 (Responsive Web Design)
- `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)/layout.tsx` — 헤더에 `<MobileNav />` 통합. 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지.
- `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` 추가로 모바일에서 오른쪽 정렬.
## Planned phases (not yet implemented)
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
## Packages to know
- `mariadb` + `@prisma/adapter-mariadb` — Prisma uses the mariadb driver directly (not mysql2)
- `@base-ui/react` — used instead of Radix UI primitives for shadcn components
- `zod` v4 — Zod v4 API differs from v3; check docs before writing schemas
- `next-auth` v5 beta — API surface differs significantly from v4
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+39
View File
@@ -0,0 +1,39 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="mysql://build:build@127.0.0.1:3306/chocomae"
ENV AUTH_SECRET="build-time-placeholder"
ENV NEXTAUTH_SECRET="build-time-placeholder"
ENV NEXTAUTH_URL="http://localhost:3000"
RUN pnpm prisma generate
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+162
View File
@@ -0,0 +1,162 @@
# chocoadmin
chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스로 분리한 웹 애플리케이션이다.
## 기술 스택
| 구분 | 기술 |
|---|---|
| 프레임워크 | Next.js 16 (App Router) |
| 언어 | TypeScript |
| 패키지 매니저 | pnpm |
| UI / CSS | Tailwind CSS v4 + shadcn/ui (Base UI) |
| Table | TanStack Table v8 |
| ORM | Prisma v7 (MariaDB adapter) |
| 인증 | NextAuth.js v5 (admin 테이블 기반) |
| 유효성 검증 | Zod v4 |
| 이메일 | Nodemailer |
| DB | MariaDB 10.11 (기존 chocomae DB 공유) |
## 구현된 기능
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환, 로그인 실패 5회 → 15분 잠금)
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
- 마에스트로 정보 수정 — 이름, 이메일, 상태, 계정유형, 만료일, 엔터코드 허용 여부 (`PATCH /api/maestros/[id]`)
- 마에스트로 암호 초기화 — 관리자가 `123456`으로 초기화 (트랜잭션 + `maestro_log`)
- 수강생 목록 — 서버사이드 페이지네이션 (`GET /api/maestros/[id]/students`)
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log` + 이메일 발송)
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log` + 이메일 발송)
- 관리자 주도 연장/업그레이드 신청 등록 — 마에스트로 상세 화면에서 직접 등록 (이메일 미발송)
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
- 이메일 자동 발송 — 연장/업그레이드 승인 후 Nodemailer로 발송, 실패 시 UI 경고 표시
## 개발 환경 설정
### 1. 의존성 설치
```bash
pnpm install
```
### 2. 환경변수 설정
```bash
cp .env.example .env.local
```
`.env.local`을 열어 실제 값으로 교체한다.
```env
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
AUTH_SECRET="..."
NEXTAUTH_SECRET="..."
AUTH_URL="http://localhost:3000"
NEXTAUTH_URL="http://localhost:3000"
```
### 3. DB 연결 확인
```bash
pnpm db:check
```
### 4. 개발 서버 실행
```bash
pnpm dev
```
[http://localhost:3000](http://localhost:3000) 에서 확인한다.
## 환경변수 전체 목록
| 변수 | 필수 | 설명 |
|---|---|---|
| `DATABASE_URL` | ✅ | MariaDB 연결 문자열 (`mysql://...`) |
| `AUTH_SECRET` | ✅ | NextAuth 세션 암호화 키 |
| `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) |
| `AUTH_URL` | ✅ | 서비스 기본 URL |
| `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) |
| `APP_ENV` | — | `local` \| `stage` \| `production` — 미설정 시 `debug` 레벨 기본값 |
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` — production 권장: `info` |
| `DB_SLOW_QUERY_MS` | — | slow query 임계값(ms), 기본 `1000` — 초과 시 `warn` 로그 |
| `MAIL_FROM_NAME` | — | 발송자 이름 (Phase 11) |
| `MAIL_FROM_ADDRESS` | — | 발송자 주소 (Phase 11) |
| `MAIL_BCC_ADDRESS` | — | BCC 주소 (Phase 11) |
| `MAIL_SMTP_HOST` | — | SMTP 호스트 (Phase 11) |
| `MAIL_SMTP_PORT` | — | SMTP 포트 (Phase 11) |
| `MAIL_SMTP_SECURE` | — | TLS 여부 (Phase 11) |
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
| `MAIL_OVERRIDE_TO` | — | 설정 시 모든 메일 수신인을 이 주소로 고정 — local/stage 테스트용 (Phase 11) |
## 배포 (Synology NAS Docker)
```bash
# 스테이지 배포
bash scripts/deploy-stage.sh
# 운영 배포 (yes 입력 확인 필요)
bash scripts/deploy-production.sh
```
각 스크립트는 `git pull``proxy-network` 확인 → `docker compose up -d --build --remove-orphans``docker exec npm nginx -s reload` 순으로 실행된다.
`docker-compose.yml``environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
## 프로젝트 구조
```
chocoadmin/
├── app/
│ ├── (auth)/login/ # 관리자 로그인
│ ├── (admin)/ # 인증 필요 화면
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
│ │ ├── maestros/ # 마에스트로 목록·상세
│ │ ├── extension-requests/
│ │ └── upgrade-requests/
│ └── api/ # API routes
│ └── maestros/[id]/
│ ├── route.ts # PATCH — 마에스트로 정보 수정
│ ├── students/route.ts # GET — 수강생 목록 (페이지네이션)
│ ├── extension-requests/ # POST — 관리자 주도 연장 신청
│ ├── upgrade-requests/ # POST — 관리자 주도 업그레이드 신청
│ └── reset-password/ # POST — 암호 초기화
├── components/
│ ├── data-table/ # TanStack Table 기반 테이블
│ ├── forms/
│ └── ui/ # shadcn/ui 컴포넌트
├── lib/
│ ├── db.ts # Prisma Client 싱글톤 (SQL/결과 로깅 포함)
│ ├── logger.ts # 공통 로거 (debug/info/warn/error)
│ ├── errors.ts # ApiError 공통 에러 클래스
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
│ ├── mail.ts # Nodemailer 이메일 발송
│ ├── constants.ts # 상태값 상수
│ ├── maestros.ts # 마에스트로 DB 쿼리
│ ├── extension-requests.ts
│ └── upgrade-requests.ts
├── scripts/
│ ├── deploy-stage.sh # 스테이지 배포 스크립트
│ └── deploy-production.sh # 운영 배포 스크립트
├── prisma/
│ └── schema.prisma # DB introspection 결과
├── docs/
│ ├── business-rules.md # 기존 서비스 비즈니스 규칙 조사
│ ├── deployment.md
│ └── plan/project-plan.md # 전체 개발 계획
├── auth.ts # NextAuth 설정
├── proxy.ts # 미인증 접근 차단 미들웨어
└── .env.example
```
## 문서
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { navItems } from "./nav-config";
export function MobileNav() {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
useEffect(() => {
setIsOpen(false);
}, [pathname]);
useEffect(() => {
if (!isOpen) {
document.body.style.overflow = "";
return;
}
document.body.style.overflow = "hidden";
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setIsOpen(false);
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
return (
<>
<button
aria-label="메뉴 열기"
className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onClick={() => setIsOpen(true)}
type="button"
>
<Menu aria-hidden="true" className="size-5" />
</button>
{/* 배경 오버레이 — 탭하면 닫힘 */}
<div
aria-hidden="true"
className={cn(
"fixed inset-0 z-20 bg-black/50 transition-opacity duration-200 md:hidden",
isOpen ? "opacity-100" : "pointer-events-none opacity-0"
)}
onClick={() => setIsOpen(false)}
/>
{/* 슬라이드인 패널 */}
<aside
aria-hidden={!isOpen}
className={cn(
"fixed inset-y-0 left-0 z-30 w-64 border-r bg-background transition-transform duration-200 md:hidden",
isOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex h-16 items-center justify-between border-b px-5">
<Link className="text-lg font-semibold" href="/">
chocoadmin
</Link>
<button
aria-label="메뉴 닫기"
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setIsOpen(false)}
type="button"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
<nav className="space-y-1 p-3">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
href={item.href}
key={item.href}
>
<Icon aria-hidden="true" className="size-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
"use server";
import { redirect } from "next/navigation";
import { signOut } from "@/auth";
export async function logoutAction() {
await signOut({ redirect: false });
redirect("/login");
}
+238
View File
@@ -0,0 +1,238 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { ExtensionRequestsTable } from "@/components/data-table/extension-requests-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
import {
getExtensionRequests,
type RawSearchParams,
} from "@/lib/extension-requests";
import { cn } from "@/lib/utils";
type ExtensionRequestsPageProps = {
searchParams: Promise<RawSearchParams>;
};
const statusOptions = [
REQUEST_STATUSES.REQUESTED,
REQUEST_STATUSES.CLOSED,
REQUEST_STATUSES.APPLIED,
] as const;
export default async function ExtensionRequestsPage({
searchParams,
}: ExtensionRequestsPageProps) {
const params = await searchParams;
const result = await getExtensionRequests(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-normal"> </h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="마에스트로명, ID, 신청ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="status"
value={result.status ?? "all"}
>
<option value="all"></option>
{statusOptions.map((status) => (
<option key={status} value={status}>
{REQUEST_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
>
</Link>
</div>
</form>
<ExtensionRequestsTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{result.pageSize}
</p>
<nav
aria-label="연장 신청 목록 페이지"
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/extension-requests?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+70
View File
@@ -0,0 +1,70 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { LogOut } from "lucide-react";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { logoutAction } from "./actions";
import { MobileNav } from "./MobileNav";
import { navItems } from "./nav-config";
export default async function AdminLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div className="min-h-screen bg-muted/40">
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r bg-background md:block">
<div className="flex h-16 items-center border-b px-5">
<Link className="text-lg font-semibold" href="/">
chocoadmin
</Link>
</div>
<nav className="space-y-1 p-3">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
href={item.href}
key={item.href}
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</Link>
);
})}
</nav>
</aside>
<div className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-b bg-background px-5">
<MobileNav />
<div className="flex-1">
<p className="text-sm font-medium">{session.user.name}</p>
<p className="text-xs text-muted-foreground">
ID {session.user.adminID}
</p>
</div>
<form action={logoutAction}>
<Button size="sm" type="submit" variant="outline">
<LogOut className="size-4" aria-hidden="true" />
</Button>
</form>
</header>
<main className="p-5">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
import type { MaestroDetail } from "@/lib/maestros";
type ExtensionRequestsSectionProps = {
maestroID: number;
accountType: number;
totalCount: number;
extensionRequests: MaestroDetail["extensionRequests"];
};
export function ExtensionRequestsSection({
maestroID,
accountType,
totalCount,
extensionRequests,
}: ExtensionRequestsSectionProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isCreating, setIsCreating] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const isPaidAccount = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
const isDisabled = isCreating || isPending;
async function handleCreate() {
if (!window.confirm("연장 신청을 등록하시겠습니까?")) return;
setIsCreating(true);
setErrorMessage("");
try {
const response = await fetch(
`/api/maestros/${maestroID}/extension-requests`,
{ method: "POST" }
);
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "연장 신청 등록에 실패했습니다.");
}
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "연장 신청 등록에 실패했습니다."
);
} finally {
setIsCreating(false);
}
}
return (
<section className="overflow-hidden rounded-lg border bg-background p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{totalCount.toLocaleString()} 20
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Button
disabled={isDisabled || !isPaidAccount}
onClick={() => void handleCreate()}
size="sm"
type="button"
>
<Plus aria-hidden="true" className="size-4" />
</Button>
{errorMessage ? (
<p className="text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
</div>
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
{["신청ID", "계정 유형", "신청일시", "상태"].map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{extensionRequests.length > 0 ? (
extensionRequests.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroExtensionID}
>
<td className="h-11 px-3 align-middle">
{request.maestroExtensionID}
</td>
<td className="h-11 px-3 align-middle">
{getAccountTypeLabel(request.accountType)}
</td>
<td className="h-11 px-3 align-middle">
{formatDateTime(request.requestedDateTime)}
</td>
<td className="h-11 px-3 align-middle">
{getRequestStatusLabel(request.status)}
</td>
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={4}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
);
}
@@ -0,0 +1,299 @@
"use client";
import { useState, useEffect } from "react";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn, formatDateTime } from "@/lib/utils";
import type { MaestroLogsResult } from "@/lib/maestros";
type LogsSectionProps = {
maestroID: number;
};
export function LogsSection({ maestroID }: LogsSectionProps) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState<MaestroLogsResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
const response = await fetch(
`/api/maestros/${maestroID}/logs?${params.toString()}`
);
if (!response.ok) {
throw new Error("처리 로그를 불러오지 못했습니다.");
}
const result = (await response.json()) as MaestroLogsResult;
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setErrorMessage(
err instanceof Error
? err.message
: "처리 로그를 불러오지 못했습니다."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [maestroID, page, pageSize]);
return (
<section className="rounded-lg border bg-background p-5">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{data ? `전체 ${data.totalCount.toLocaleString()}` : "로딩 중..."}
</p>
</div>
{errorMessage ? (
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
) : (
<>
<LogTable isLoading={isLoading} result={data} />
{data ? (
<LogPagination
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
page={data.page}
pageSize={pageSize}
totalPages={data.totalPages}
/>
) : null}
</>
)}
</section>
);
}
function LogTable({
result,
isLoading,
}: {
result: MaestroLogsResult | null;
isLoading: boolean;
}) {
if (isLoading || !result) {
return (
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
<p className="text-sm text-muted-foreground"> ...</p>
</div>
);
}
return (
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
LogID
</th>
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{result.items.length > 0 ? (
result.items.map((log) => (
<tr
className="border-b last:border-b-0"
key={log.maestroLogID}
>
<td className="h-11 px-3 align-middle">
<span className="font-mono text-xs tabular-nums">
{log.maestroLogID}
</span>
</td>
<td className="h-11 px-3 align-middle">{log.type}</td>
<td className="h-11 px-3 align-middle">
{formatDateTime(new Date(log.logDateTime))}
</td>
<td className="h-11 max-w-[360px] break-words px-3 align-middle">
{log.remark}
</td>
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={4}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function LogPagination({
page,
pageSize,
totalPages,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
totalPages: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}) {
const showNav = totalPages > 1;
const paginationItems = buildPaginationItems(page, totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < totalPages;
return (
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<select
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
onChange={(e) => onPageSizeChange(Number(e.target.value))}
value={pageSize}
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted-foreground"> </span>
</label>
{showNav ? (
<nav
aria-label="처리 로그 페이지"
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
{!hasFirstPage ? (
<Button
aria-label="처음 페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(1)}
type="button"
>
<ChevronsLeft aria-hidden="true" className="size-4" />
</Button>
) : null}
{hasPreviousPageGroup ? (
<Button
aria-label="이전 5페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(Math.max(1, page - 5))}
type="button"
>
<ChevronLeft aria-hidden="true" className="size-4" />
</Button>
) : null}
{paginationItems.map((item) => (
<Button
aria-current={item === page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
key={item}
onClick={() => onPageChange(item)}
type="button"
>
{item}
</Button>
))}
{hasNextPageGroup ? (
<Button
aria-label="다음 5페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
type="button"
>
<ChevronRight aria-hidden="true" className="size-4" />
</Button>
) : null}
{!hasLastPage ? (
<Button
aria-label="끝 페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(totalPages)}
type="button"
>
<ChevronsRight aria-hidden="true" className="size-4" />
</Button>
) : null}
</nav>
) : null}
</div>
);
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
@@ -0,0 +1,301 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { KeyRound, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
ACCOUNT_TYPE_LABELS,
ACCOUNT_TYPES,
ACTIVATE_STATUS_LABELS,
ACTIVATE_STATUSES,
} from "@/lib/constants";
import { formatDate, formatDateTime, toDatetimeLocal } from "@/lib/utils";
import type { MaestroDetail } from "@/lib/maestros";
type MaestroEditFormProps = {
maestro: MaestroDetail["maestro"];
playerCount: number;
};
const paidAccountTypeOptions = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
const activateStatusOptions = [
ACTIVATE_STATUSES.PENDING,
ACTIVATE_STATUSES.TRIAL,
ACTIVATE_STATUSES.ACTIVE,
ACTIVATE_STATUSES.CANCELED,
] as const;
const inputClass =
"h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30 disabled:opacity-50";
export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isSaving, setIsSaving] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [name, setName] = useState(maestro.name);
const [email, setEmail] = useState(maestro.email);
const [activateStatus, setActivateStatus] = useState(maestro.activateStatus);
const [accountType, setAccountType] = useState(maestro.accountType);
const [availableActivateDateTime, setAvailableActivateDateTime] = useState(
toDatetimeLocal(maestro.availableActivateDateTime)
);
const [allowEditEnterCode, setAllowEditEnterCode] = useState(
maestro.allowEditEnterCode
);
const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState("");
const isDisabled = isSaving || isPending || isResettingPassword;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!window.confirm("마에스트로 정보를 저장하시겠습니까?")) return;
setIsSaving(true);
setErrorMessage("");
setSuccessMessage("");
try {
const response = await fetch(`/api/maestros/${maestro.maestroID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
email,
activateStatus,
accountType,
availableActivateDateTime,
allowEditEnterCode,
}),
});
const body = (await response.json().catch(() => null)) as {
changed?: boolean;
message?: string;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "저장에 실패했습니다.");
}
if (body?.changed) {
setSuccessMessage("저장 완료");
startTransition(() => {
router.refresh();
});
} else {
setSuccessMessage("변경 사항이 없습니다.");
}
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "저장에 실패했습니다."
);
} finally {
setIsSaving(false);
}
}
async function handleResetPassword() {
if (
!window.confirm(
`${maestro.name} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?`
)
)
return;
setIsResettingPassword(true);
setErrorMessage("");
setSuccessMessage("");
try {
const response = await fetch(
`/api/maestros/${maestro.maestroID}/reset-password`,
{ method: "POST" }
);
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "암호 초기화에 실패했습니다.");
}
setSuccessMessage("암호 초기화 완료");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "암호 초기화에 실패했습니다."
);
} finally {
setIsResettingPassword(false);
}
}
return (
<form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5">
<div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
<FormField label="이름">
<input
className={inputClass}
disabled={isDisabled}
maxLength={50}
onChange={(e) => setName(e.target.value)}
required
type="text"
value={name}
/>
</FormField>
<FormField label="이메일">
<input
className={inputClass}
disabled={isDisabled}
maxLength={50}
onChange={(e) => setEmail(e.target.value)}
required
type="email"
value={email}
/>
</FormField>
<FormField label="상태">
<select
className={inputClass}
disabled={isDisabled}
onChange={(e) => setActivateStatus(Number(e.target.value))}
value={activateStatus}
>
{activateStatusOptions.map((s) => (
<option key={s} value={s}>
{ACTIVATE_STATUS_LABELS[s]}
</option>
))}
</select>
</FormField>
<FormField label="계정 유형">
<select
className={inputClass}
disabled={isDisabled}
onChange={(e) => setAccountType(Number(e.target.value))}
value={accountType}
>
{paidAccountTypeOptions.map((t) => (
<option key={t} value={t}>
{ACCOUNT_TYPE_LABELS[t]}
</option>
))}
</select>
</FormField>
<FormField label="사용 가능일">
<input
className={inputClass}
disabled={isDisabled}
onChange={(e) => setAvailableActivateDateTime(e.target.value)}
required
type="datetime-local"
value={availableActivateDateTime}
/>
</FormField>
<FormField label="입장코드 수정">
<select
className={inputClass}
disabled={isDisabled}
onChange={(e) => setAllowEditEnterCode(Number(e.target.value))}
value={allowEditEnterCode}
>
<option value={1}></option>
<option value={0}></option>
</select>
</FormField>
</div>
<div className="grid gap-x-8 gap-y-3 border-t pt-4 text-sm md:grid-cols-2 xl:grid-cols-3">
<ReadOnlyItem
label="약관 동의일"
value={formatDateTime(maestro.acceptClausesDateTime)}
/>
<ReadOnlyItem
label="DB 학생 수"
value={playerCount.toLocaleString()}
/>
<ReadOnlyItem
label="PlayerCount"
value={maestro.playerCount.toLocaleString()}
/>
<ReadOnlyItem
label="체험 MaestroID"
value={maestro.maestroTestID?.toString() ?? "-"}
/>
<ReadOnlyItem
label="사용 가능일 (현재)"
value={formatDate(maestro.availableActivateDateTime)}
/>
</div>
<div className="flex items-center gap-3">
<Button disabled={isDisabled} size="sm" type="submit">
<Save aria-hidden="true" className="size-4" />
</Button>
<Button
disabled={isDisabled}
onClick={() => void handleResetPassword()}
size="sm"
type="button"
variant="outline"
>
<KeyRound aria-hidden="true" className="size-4" />
</Button>
{successMessage ? (
<p className="text-sm text-green-700 dark:text-green-400">
{successMessage}
</p>
) : null}
{errorMessage ? (
<p className="text-sm text-destructive">{errorMessage}</p>
) : null}
</div>
</form>
);
}
function FormField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}
function ReadOnlyItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 font-medium">{value}</dd>
</div>
);
}
@@ -0,0 +1,325 @@
"use client";
import { useState, useEffect } from "react";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { MaestroStudentsResult } from "@/lib/maestros";
type StudentsSectionProps = {
maestroID: number;
};
export function StudentsSection({ maestroID }: StudentsSectionProps) {
const [q, setQ] = useState("");
const [submittedQ, setSubmittedQ] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState<MaestroStudentsResult | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
q: submittedQ,
});
const response = await fetch(
`/api/maestros/${maestroID}/students?${params.toString()}`
);
if (!response.ok) {
throw new Error("학생 목록을 불러오지 못했습니다.");
}
const result = (await response.json()) as MaestroStudentsResult;
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setErrorMessage(
err instanceof Error
? err.message
: "학생 목록을 불러오지 못했습니다."
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [maestroID, submittedQ, page, pageSize]);
function handleSearch(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPage(1);
setSubmittedQ(q);
}
return (
<section className="rounded-lg border bg-background p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{data
? `전체 ${data.totalCount.toLocaleString()}${submittedQ ? ` — "${submittedQ}" 검색 결과` : ""}`
: "로딩 중..."}
</p>
</div>
<form className="flex gap-2" onSubmit={handleSearch}>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<input
className="h-8 w-44 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
onChange={(e) => setQ(e.target.value)}
placeholder="이름 검색"
type="search"
value={q}
/>
</div>
<Button size="sm" type="submit">
</Button>
</form>
</div>
{errorMessage ? (
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
) : (
<>
<StudentTable isLoading={isLoading} result={data} />
{data ? (
<StudentPagination
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
page={data.page}
pageSize={pageSize}
totalPages={data.totalPages}
/>
) : null}
</>
)}
</section>
);
}
function StudentTable({
result,
isLoading,
}: {
result: MaestroStudentsResult | null;
isLoading: boolean;
}) {
if (isLoading || !result) {
return (
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
<p className="text-sm text-muted-foreground"> ...</p>
</div>
);
}
return (
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[360px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
PlayerID
</th>
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
</th>
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{result.items.length > 0 ? (
result.items.map((player) => (
<tr
className="border-b last:border-b-0"
key={player.playerID}
>
<td className="h-11 px-3 align-middle">
<span className="font-mono text-xs tabular-nums">
{player.playerID}
</span>
</td>
<td className="h-11 px-3 align-middle">{player.name}</td>
<td className="h-11 px-3 align-middle">
<span className="font-mono text-xs">{player.enterCode}</span>
</td>
</tr>
))
) : (
<tr>
<td
className="h-24 text-center text-sm text-muted-foreground"
colSpan={3}
>
{result.q
? "검색 결과가 없습니다."
: "등록된 학생이 없습니다."}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function StudentPagination({
page,
pageSize,
totalPages,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
totalPages: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}) {
const showNav = totalPages > 1;
const paginationItems = buildPaginationItems(page, totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < totalPages;
return (
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<select
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
onChange={(e) => onPageSizeChange(Number(e.target.value))}
value={pageSize}
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className="text-sm text-muted-foreground"> </span>
</label>
{showNav ? <nav
aria-label="학생 목록 페이지"
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
{!hasFirstPage ? (
<Button
aria-label="처음 페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(1)}
type="button"
>
<ChevronsLeft aria-hidden="true" className="size-4" />
</Button>
) : null}
{hasPreviousPageGroup ? (
<Button
aria-label="이전 5페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(Math.max(1, page - 5))}
type="button"
>
<ChevronLeft aria-hidden="true" className="size-4" />
</Button>
) : null}
{paginationItems.map((item) => (
<Button
aria-current={item === page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
key={item}
onClick={() => onPageChange(item)}
type="button"
>
{item}
</Button>
))}
{hasNextPageGroup ? (
<Button
aria-label="다음 5페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
type="button"
>
<ChevronRight aria-hidden="true" className="size-4" />
</Button>
) : null}
{!hasLastPage ? (
<Button
aria-label="끝 페이지"
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
onClick={() => onPageChange(totalPages)}
type="button"
>
<ChevronsRight aria-hidden="true" className="size-4" />
</Button>
) : null}
</nav> : null}
</div>
);
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
@@ -0,0 +1,194 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ACCOUNT_TYPES, ACTIVATE_STATUSES } from "@/lib/constants";
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
import type { MaestroDetail } from "@/lib/maestros";
type UpgradeRequestsSectionProps = {
maestroID: number;
accountType: number;
activateStatus: number;
totalCount: number;
upgradeRequests: MaestroDetail["upgradeRequests"];
};
const upgradeOptions = [
{ value: ACCOUNT_TYPES.BASIC_20, label: "1만원 (20명)" },
{ value: ACCOUNT_TYPES.STANDARD_50, label: "2만원 (50명)" },
{ value: ACCOUNT_TYPES.PRO_100, label: "3만원 (100명)" },
{ value: ACCOUNT_TYPES.SCHOOL_500, label: "4만원 (500명)" },
{ value: ACCOUNT_TYPES.SCHOOL_1000, label: "5만원 (1,000명)" },
] as const;
const selectClass =
"h-9 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30 disabled:opacity-50";
export function UpgradeRequestsSection({
maestroID,
accountType,
activateStatus,
totalCount,
upgradeRequests,
}: UpgradeRequestsSectionProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isCreating, setIsCreating] = useState(false);
const [selectedAccountType, setSelectedAccountType] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const isTrial = activateStatus === ACTIVATE_STATUSES.TRIAL;
const isUpgradeEnabled =
selectedAccountType !== null &&
(isTrial ? selectedAccountType >= accountType : selectedAccountType > accountType);
const isDisabled = isCreating || isPending;
async function handleUpgrade() {
if (selectedAccountType === null) return;
if (!window.confirm("업그레이드 신청을 등록하시겠습니까?")) return;
setIsCreating(true);
setErrorMessage("");
try {
const response = await fetch(
`/api/maestros/${maestroID}/upgrade-requests`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestedAccountType: selectedAccountType }),
}
);
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "업그레이드 신청 등록에 실패했습니다.");
}
setSelectedAccountType(null);
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "업그레이드 신청 등록에 실패했습니다."
);
} finally {
setIsCreating(false);
}
}
return (
<section className="overflow-hidden rounded-lg border bg-background p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{totalCount.toLocaleString()} 20
</p>
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex flex-wrap items-center gap-2">
<select
className={selectClass}
disabled={isDisabled}
onChange={(e) =>
setSelectedAccountType(
e.target.value === "" ? null : Number(e.target.value)
)
}
value={selectedAccountType ?? ""}
>
<option value=""> </option>
{upgradeOptions.map((opt) => (
<option
disabled={isTrial ? opt.value < accountType : opt.value <= accountType}
key={opt.value}
value={opt.value}
>
{opt.label}
</option>
))}
</select>
<Button
disabled={isDisabled || !isUpgradeEnabled}
onClick={() => void handleUpgrade()}
size="sm"
type="button"
>
<ArrowUp aria-hidden="true" className="size-4" />
</Button>
</div>
{errorMessage ? (
<p className="text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
</div>
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
{["신청ID", "현재", "요청", "신청일시", "상태"].map(
(header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header}
>
{header}
</th>
)
)}
</tr>
</thead>
<tbody>
{upgradeRequests.length > 0 ? (
upgradeRequests.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroUpgradeID}
>
<td className="h-11 px-3 align-middle">
{request.maestroUpgradeID}
</td>
<td className="h-11 px-3 align-middle">
{getAccountTypeLabel(request.registeredAccountType)}
</td>
<td className="h-11 px-3 align-middle">
{getAccountTypeLabel(request.requestedAccountType)}
</td>
<td className="h-11 px-3 align-middle">
{formatDateTime(request.requestedDateTime)}
</td>
<td className="h-11 px-3 align-middle">
{getRequestStatusLabel(request.status)}
</td>
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={5}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
);
}
+124
View File
@@ -0,0 +1,124 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { getMaestroDetail } from "@/lib/maestros";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
} from "@/lib/utils";
import { MaestroEditForm } from "./MaestroEditForm";
import { StudentsSection } from "./StudentsSection";
import { ExtensionRequestsSection } from "./ExtensionRequestsSection";
import { UpgradeRequestsSection } from "./UpgradeRequestsSection";
import { LogsSection } from "./LogsSection";
type MaestroDetailPageProps = {
params: Promise<{
maestroId: string;
}>;
};
export default async function MaestroDetailPage({
params,
}: MaestroDetailPageProps) {
const { maestroId } = await params;
const maestroID = Number(maestroId);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
notFound();
}
const detail = await getMaestroDetail(maestroID);
if (!detail) {
notFound();
}
const { maestro } = detail;
return (
<section className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<Link
className={buttonVariants({
variant: "ghost",
size: "sm",
className: "mb-2 -ml-2",
})}
href="/maestros"
>
<ArrowLeft className="size-4" aria-hidden="true" />
</Link>
<h1 className="text-2xl font-semibold tracking-normal">
{maestro.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
MaestroID {maestro.maestroID} · {maestro.email}
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-4">
<SummaryCard
label="상태"
value={getActivateStatusLabel(maestro.activateStatus)}
/>
<SummaryCard
label="계정 유형"
value={getAccountTypeLabel(maestro.accountType)}
/>
<SummaryCard
label="학생 수"
value={detail.counts.players.toLocaleString()}
/>
<SummaryCard
label="사용 가능일"
value={formatDate(maestro.availableActivateDateTime)}
/>
</div>
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
<MaestroEditForm
maestro={detail.maestro}
playerCount={detail.counts.players}
/>
</section>
<StudentsSection maestroID={maestroID} />
<div className="grid gap-4 xl:grid-cols-2">
<ExtensionRequestsSection
accountType={maestro.accountType}
extensionRequests={detail.extensionRequests}
maestroID={maestroID}
totalCount={detail.counts.extensionRequests}
/>
<UpgradeRequestsSection
accountType={maestro.accountType}
activateStatus={maestro.activateStatus}
maestroID={maestroID}
totalCount={detail.counts.upgradeRequests}
upgradeRequests={detail.upgradeRequests}
/>
</div>
<LogsSection maestroID={maestroID} />
</section>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<article className="rounded-lg border bg-background p-4">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-2 text-lg font-semibold">{value}</p>
</article>
);
}
+296
View File
@@ -0,0 +1,296 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { MaestrosTable } from "@/components/data-table/maestros-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import {
ACCOUNT_TYPE_LABELS,
ACCOUNT_TYPES,
ACTIVATE_STATUS_LABELS,
ACTIVATE_STATUSES,
} from "@/lib/constants";
import { getMaestros, type RawSearchParams } from "@/lib/maestros";
import { cn } from "@/lib/utils";
type MaestrosPageProps = {
searchParams: Promise<RawSearchParams>;
};
const activateStatusOptions = [
ACTIVATE_STATUSES.PENDING,
ACTIVATE_STATUSES.TRIAL,
ACTIVATE_STATUSES.ACTIVE,
ACTIVATE_STATUSES.CANCELED,
] as const;
const accountTypeOptions = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
const sortOptions = [
{ value: "id-desc", label: "ID 최신순" },
{ value: "id-asc", label: "ID 오래된순" },
{ value: "joinedAt-desc", label: "가입일 최신순" },
{ value: "joinedAt-asc", label: "가입일 오래된순" },
{ value: "availableAt-desc", label: "만료일 최신순" },
{ value: "availableAt-asc", label: "만료일 오래된순" },
] as const;
export default async function MaestrosPage({
searchParams,
}: MaestrosPageProps) {
const params = await searchParams;
const result = await getMaestros(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal"></h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1fr)_160px_180px_190px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="이름, 이메일, ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="activateStatus"
value={result.activateStatus ?? "all"}
>
<option value="all"></option>
{activateStatusOptions.map((status) => (
<option key={status} value={status}>
{ACTIVATE_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="accountType"
value={result.accountType ?? "all"}
>
<option value="all"></option>
{accountTypeOptions.map((accountType) => (
<option key={accountType} value={accountType}>
{ACCOUNT_TYPE_LABELS[accountType]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="sort"
value={result.sort}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href="/maestros"
>
</Link>
</div>
</form>
<MaestrosTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form className="flex items-center gap-2" method="get">
{Object.entries(params).flatMap(([key, value]) => {
if (key === "page" || key === "pageSize") return [];
const v = Array.isArray(value) ? value[0] : value;
if (!v) return [];
return [<input key={key} name={key} type="hidden" value={v} />];
})}
<span className="text-sm text-muted-foreground"></span>
<AutoSubmitSelect
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</AutoSubmitSelect>
<span className="text-sm text-muted-foreground"> </span>
</form>
<nav
aria-label="마에스트로 목록 페이지"
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/maestros?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+13
View File
@@ -0,0 +1,13 @@
import {
ArrowUpCircle,
ClipboardList,
GraduationCap,
LayoutDashboard,
} from "lucide-react";
export const navItems = [
{ href: "/", label: "대시보드", icon: LayoutDashboard },
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
];
+55
View File
@@ -0,0 +1,55 @@
import { Database, ShieldCheck, Table2 } from "lucide-react";
import { auth } from "@/auth";
const checks = [
{
label: "Next.js 16",
value: "App Router",
icon: ShieldCheck,
},
{
label: "Prisma",
value: "MariaDB introspected",
icon: Database,
},
{
label: "TanStack Table",
value: "Installed",
icon: Table2,
},
];
export default async function Home() {
const session = await auth();
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-normal"></h1>
<p className="mt-1 text-sm text-muted-foreground">
{session?.user.name} .
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{checks.map((item) => {
const Icon = item.icon;
return (
<article
className="rounded-lg border bg-card p-5 text-card-foreground"
key={item.label}
>
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
<Icon className="size-5" aria-hidden="true" />
</div>
<h2 className="text-base font-medium">{item.label}</h2>
<p className="mt-1 text-sm text-muted-foreground">{item.value}</p>
</article>
);
})}
</div>
</section>
);
}
+240
View File
@@ -0,0 +1,240 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { UpgradeRequestsTable } from "@/components/data-table/upgrade-requests-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
import {
getUpgradeRequests,
type RawSearchParams,
} from "@/lib/upgrade-requests";
import { cn } from "@/lib/utils";
type UpgradeRequestsPageProps = {
searchParams: Promise<RawSearchParams>;
};
const statusOptions = [
REQUEST_STATUSES.REQUESTED,
REQUEST_STATUSES.CLOSED,
REQUEST_STATUSES.APPLIED,
] as const;
export default async function UpgradeRequestsPage({
searchParams,
}: UpgradeRequestsPageProps) {
const params = await searchParams;
const result = await getUpgradeRequests(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-normal">
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="마에스트로명, ID, 신청ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="status"
value={result.status ?? "all"}
>
<option value="all"></option>
{statusOptions.map((status) => (
<option key={status} value={status}>
{REQUEST_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
>
</Link>
</div>
</form>
<UpgradeRequestsTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{result.pageSize}
</p>
<nav
aria-label="업그레이드 신청 목록 페이지"
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/upgrade-requests?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
import { signIn } from "@/auth";
import { logger } from "@/lib/logger";
function sanitizeCallbackUrl(raw: string): string {
try {
const parsed = new URL(raw, "http://x");
if (parsed.origin !== "http://x") return "/";
} catch {
return "/";
}
return raw.startsWith("/") ? raw : "/";
}
export async function loginAction(formData: FormData) {
const callbackUrl = sanitizeCallbackUrl(
String(formData.get("callbackUrl") ?? "/")
);
try {
const nextAuthRedirectUrl = await signIn("credentials", {
adminName: formData.get("adminName"),
password: formData.get("password"),
redirect: false,
redirectTo: callbackUrl,
});
logger.info("auth", "login redirect", {
nextAuthRedirectUrl: String(nextAuthRedirectUrl),
callbackUrl,
});
} catch (error) {
if (error instanceof AuthError) {
redirect(
`/login?error=CredentialsSignin&callbackUrl=${encodeURIComponent(callbackUrl)}`
);
}
throw error;
}
redirect(callbackUrl);
}
+79
View File
@@ -0,0 +1,79 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { loginAction } from "./actions";
type LoginPageProps = {
searchParams: Promise<{
callbackUrl?: string;
error?: string;
}>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await auth();
if (session) {
redirect("/");
}
const params = await searchParams;
const callbackUrl = params.callbackUrl ?? "/";
const hasError = params.error === "CredentialsSignin";
return (
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-10">
<section className="w-full max-w-md rounded-lg border bg-background p-8 shadow-sm">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-normal">chocoadmin</h1>
<p className="mt-2 text-sm text-muted-foreground"> </p>
</div>
{hasError ? (
<div className="mb-5 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
.
</div>
) : null}
<form action={loginAction} className="space-y-5">
<input name="callbackUrl" type="hidden" value={callbackUrl} />
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="adminName">
</label>
<input
autoComplete="username"
autoFocus
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
id="adminName"
name="adminName"
required
type="text"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password">
</label>
<input
autoComplete="current-password"
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
id="password"
name="password"
required
type="password"
/>
</div>
<Button className="w-full" type="submit">
</Button>
</form>
</section>
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "@/auth";
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
approveExtensionRequest,
cancelExtensionRequest,
} from "@/lib/extension-requests";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
import { sendExtensionDoneEmail } from "@/lib/mail";
import { formatDate } from "@/lib/utils";
const CTX = "extension-requests/[id]";
const patchBodySchema = z.object({
action: z.enum(["approve", "cancel"]),
});
export const PATCH = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroExtensionID = Number(id);
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
throw new ApiError("Invalid extension request id", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
throw new ApiError("Invalid action", 400);
}
if (body.data.action === "approve") {
const result = await approveExtensionRequest(maestroExtensionID);
logger.info(CTX, "approved", {
id: maestroExtensionID,
duration: Date.now() - t0,
});
const emailSent = await sendExtensionDoneEmail(
result.maestroName,
result.maestroEmail,
formatDate(result.availableActivateDateTime)
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
}
await cancelExtensionRequest(maestroExtensionID);
logger.info(CTX, "cancelled", {
id: maestroExtensionID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true });
}
);
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getExtensionRequests } from "@/lib/extension-requests";
import { withApiHandler } from "@/lib/api-handler";
export const GET = withApiHandler("extension-requests", async (request) => {
const url = new URL(request.url);
const result = await getExtensionRequests(url.searchParams);
return NextResponse.json(result);
});
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { createExtensionRequest } from "@/lib/extension-requests";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
const CTX = "maestros/[id]/extension-requests";
export const POST = withApiHandler<{ id: string }>(
CTX,
async (_request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const result = await createExtensionRequest(maestroID);
logger.info(CTX, "created", {
maestroID,
maestroExtensionID: result.maestroExtensionID,
duration: Date.now() - t0,
});
return NextResponse.json(result, { status: 201 });
}
);
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { getMaestroLogs } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
const CTX = "maestros/[id]/logs";
export const GET = withApiHandler<{ id: string }>(
CTX,
async (request, { params }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const url = new URL(request.url);
const result = await getMaestroLogs(maestroID, url.searchParams);
if (!result) {
throw new ApiError("Not found", 404);
}
return NextResponse.json(result);
}
);
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { resetMaestroPassword } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
const CTX = "maestros/[id]/reset-password";
export const POST = withApiHandler<{ id: string }>(
CTX,
async (_request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const found = await resetMaestroPassword(maestroID);
if (!found) {
throw new ApiError("Not found", 404);
}
logger.info(CTX, "password reset", {
id: maestroID,
duration: Date.now() - t0,
});
return NextResponse.json({ success: true });
}
);
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { getMaestroDetail, updateMaestro, updateMaestroSchema } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
const CTX = "maestros/[id]";
export const GET = withApiHandler<{ id: string }>(
CTX,
async (_request, { params }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const result = await getMaestroDetail(maestroID);
if (!result) {
throw new ApiError("Not found", 404);
}
return NextResponse.json(result);
}
);
export const PATCH = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const body = updateMaestroSchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
throw new ApiError("Invalid request body", 400);
}
const result = await updateMaestro(maestroID, body.data);
if (!result) {
throw new ApiError("Not found", 404);
}
logger.info(CTX, result.changed ? "updated" : "no change", {
id: maestroID,
duration: Date.now() - t0,
});
return NextResponse.json(result);
}
);
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { getMaestroStudents } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
const CTX = "maestros/[id]/students";
export const GET = withApiHandler<{ id: string }>(
CTX,
async (request, { params }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const url = new URL(request.url);
const result = await getMaestroStudents(maestroID, url.searchParams);
if (!result) {
throw new ApiError("Not found", 404);
}
return NextResponse.json(result);
}
);
@@ -0,0 +1,50 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { createUpgradeRequest } from "@/lib/upgrade-requests";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
const CTX = "maestros/[id]/upgrade-requests";
const postBodySchema = z.object({
requestedAccountType: z.coerce
.number()
.int()
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v)),
});
export const POST = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const body = postBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
throw new ApiError("Invalid request body", 400);
}
const result = await createUpgradeRequest(
maestroID,
body.data.requestedAccountType
);
logger.info(CTX, "created", {
maestroID,
maestroUpgradeID: result.maestroUpgradeID,
requestedAccountType: body.data.requestedAccountType,
duration: Date.now() - t0,
});
return NextResponse.json(result, { status: 201 });
}
);
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getMaestros } from "@/lib/maestros";
import { withApiHandler } from "@/lib/api-handler";
export const GET = withApiHandler("maestros", async (request) => {
const url = new URL(request.url);
const result = await getMaestros(url.searchParams);
return NextResponse.json(result);
});
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
approveUpgradeRequest,
rejectUpgradeRequest,
} from "@/lib/upgrade-requests";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
import { sendUpgradeDoneEmail } from "@/lib/mail";
import { formatDate } from "@/lib/utils";
const CTX = "upgrade-requests/[id]";
const patchBodySchema = z.object({
action: z.enum(["approve", "reject"]),
});
export const PATCH = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroUpgradeID = Number(id);
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
throw new ApiError("Invalid upgrade request id", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
throw new ApiError("Invalid action", 400);
}
if (body.data.action === "approve") {
const result = await approveUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "approved", {
id: maestroUpgradeID,
duration: Date.now() - t0,
});
const emailSent = await sendUpgradeDoneEmail(
result.maestroName,
result.maestroEmail,
result.registeredActivateStatus,
result.registeredAccountType,
result.requestedAccountType,
formatDate(result.availableActivateDateTime)
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
}
await rejectUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "rejected", {
id: maestroUpgradeID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true });
}
);
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getUpgradeRequests } from "@/lib/upgrade-requests";
import { withApiHandler } from "@/lib/api-handler";
export const GET = withApiHandler("upgrade-requests", async (request) => {
const url = new URL(request.url);
const result = await getUpgradeRequests(url.searchParams);
return NextResponse.json(result);
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "chocoadmin",
description: "Chocomae admin service",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+170
View File
@@ -0,0 +1,170 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { ACTIVATE_STATUSES } from "@/lib/constants";
import { logger } from "@/lib/logger";
const AUTH_CTX = "auth";
type AttemptRecord = { count: number; until: number };
const loginAttempts = new Map<string, AttemptRecord>();
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
function isLockedOut(adminName: string): boolean {
const record = loginAttempts.get(adminName);
if (!record || record.count < MAX_ATTEMPTS) return false;
if (record.until > Date.now()) return true;
loginAttempts.delete(adminName);
return false;
}
function recordFailure(adminName: string): void {
const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 };
record.count += 1;
if (record.count >= MAX_ATTEMPTS) {
record.until = Date.now() + LOCKOUT_MS;
}
loginAttempts.set(adminName, record);
}
function clearAttempts(adminName: string): void {
loginAttempts.delete(adminName);
}
const credentialsSchema = z.object({
adminName: z.string().trim().min(1),
password: z.string().min(1),
});
type AdminAuthRow = {
AdminID: bigint | number;
Name: string;
Email: string;
AccountType: bigint | number;
ActivateStatus: bigint | number;
};
declare module "next-auth" {
interface Session {
user: {
adminID: number;
accountType: number;
activateStatus: number;
} & DefaultSession["user"];
}
interface User {
accountType: number;
activateStatus: number;
}
}
declare module "next-auth/jwt" {
interface JWT {
accountType?: number;
activateStatus?: number;
}
}
const appEnv = process.env.APP_ENV ?? "local";
const isSecure = process.env.NODE_ENV === "production";
const sessionCookieName = `chocoadmin-${appEnv}.session-token`;
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
maxAge: 8 * 60 * 60,
},
cookies: {
sessionToken: {
name: sessionCookieName,
options: {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: isSecure,
},
},
},
providers: [
Credentials({
credentials: {
adminName: { label: "관리자 아이디", type: "text" },
password: { label: "암호", type: "password" },
},
async authorize(credentials) {
const parsedCredentials = credentialsSchema.safeParse(credentials);
if (!parsedCredentials.success) {
return null;
}
const { adminName, password } = parsedCredentials.data;
if (isLockedOut(adminName)) {
logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName });
return null;
}
const { db } = await import("@/lib/db");
const admins = await db.$queryRaw<AdminAuthRow[]>`
SELECT AdminID, Name, Email, AccountType, ActivateStatus
FROM admin
WHERE Name = ${adminName} AND Password = PASSWORD(${password})
LIMIT 1
`;
const admin = admins[0];
const adminID = Number(admin?.AdminID);
const accountType = Number(admin?.AccountType);
const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
recordFailure(adminName);
logger.warn(AUTH_CTX, "login failed", { adminName });
return null;
}
clearAttempts(adminName);
logger.info(AUTH_CTX, "login success", { adminName, adminID });
return {
id: String(adminID),
name: admin.Name,
email: admin.Email,
accountType,
activateStatus,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.accountType = user.accountType;
token.activateStatus = user.activateStatus;
}
return token;
},
session({ session, token }) {
return {
...session,
user: {
...session.user,
adminID: Number(token.sub),
accountType: Number(token.accountType),
activateStatus: Number(token.activateStatus),
},
};
},
},
});
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,210 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, X } from "lucide-react";
import { useState, useTransition } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUSES } from "@/lib/constants";
import type { ExtensionRequestListItem } from "@/lib/extension-requests";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
} from "@/lib/utils";
type ExtensionRequestsTableProps = {
data: ExtensionRequestListItem[];
};
export function ExtensionRequestsTable({ data }: ExtensionRequestsTableProps) {
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<HeaderCell>ID</HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroExtensionID}
>
<BodyCell>
<span className="font-mono text-xs tabular-nums">
{request.maestroExtensionID}
</span>
</BodyCell>
<BodyCell>
<Link
className={buttonVariants({
variant: "link",
size: "sm",
className: "h-auto p-0 font-medium",
})}
href={`/maestros/${request.maestroID}`}
>
{request.maestroName}
</Link>
<p className="mt-1 text-xs text-muted-foreground">
MaestroID {request.maestroID}
</p>
</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.requestedAccountType)}
</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.currentAccountType)}
</BodyCell>
<BodyCell>
{getActivateStatusLabel(request.activateStatus)}
</BodyCell>
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
<BodyCell>
<ExtensionRequestActions request={request} />
</BodyCell>
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={9}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function ExtensionRequestActions({
request,
}: {
request: ExtensionRequestListItem;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>;
}
async function submitAction(action: "approve" | "cancel") {
const actionLabel = action === "approve" ? "승인" : "취소";
if (!window.confirm(`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`)) {
return;
}
setErrorMessage("");
try {
const response = await fetch(
`/api/extension-requests/${request.maestroExtensionID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}
);
const body = (await response.json().catch(() => null)) as {
message?: string;
emailSent?: boolean;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
}
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
}
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "연장 신청 처리에 실패했습니다."
);
}
}
return (
<div className="space-y-2">
{isRequested ? (
<div className="flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => void submitAction("approve")}
size="sm"
type="button"
>
<Check className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={isPending}
onClick={() => void submitAction("cancel")}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
);
}
function HeaderCell({ children }: { children: React.ReactNode }) {
return (
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
{children}
</th>
);
}
function BodyCell({ children }: { children: React.ReactNode }) {
return <td className="h-12 px-3 align-middle">{children}</td>;
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useRouter } from "next/navigation";
import {
flexRender,
getCoreRowModel,
type ColumnDef,
useReactTable,
} from "@tanstack/react-table";
import { useMemo } from "react";
import type { MaestroListItem } from "@/lib/maestros";
import { cn } from "@/lib/utils";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
} from "@/lib/utils";
type MaestrosTableProps = {
data: MaestroListItem[];
};
export function MaestrosTable({ data }: MaestrosTableProps) {
const router = useRouter();
const columns = useMemo<ColumnDef<MaestroListItem>[]>(
() => [
{
accessorKey: "maestroID",
header: "ID",
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.maestroID}
</span>
),
},
{
accessorKey: "name",
header: "이름",
cell: ({ row }) => (
<div className="min-w-32">
<p className="font-medium">{row.original.name}</p>
<p className="mt-1 text-xs text-muted-foreground md:hidden">
{row.original.email}
</p>
</div>
),
},
{
accessorKey: "email",
header: "이메일",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span>
),
},
{
accessorKey: "accountType",
header: "계정 유형",
cell: ({ row }) => getAccountTypeLabel(row.original.accountType),
},
{
accessorKey: "activateStatus",
header: "상태",
cell: ({ row }) => (
<span
className={cn(
"inline-flex h-6 items-center rounded-md border px-2 text-xs font-medium",
getActivateStatusBadgeClass(row.original)
)}
>
{getActivateStatusLabel(row.original.activateStatus)}
</span>
),
},
{
accessorKey: "playerCount",
header: "학생 수",
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.playerCount.toLocaleString()}
</span>
),
},
{
accessorKey: "acceptClausesDateTime",
header: "가입일",
cell: ({ row }) => formatDate(row.original.acceptClausesDateTime),
},
{
accessorKey: "availableActivateDateTime",
header: "만료일",
cell: ({ row }) => formatDate(row.original.availableActivateDateTime),
},
],
[]
);
// TanStack Table manages internal functions that React Compiler should not memoize.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] border-collapse text-sm">
<thead className="bg-muted/60">
{table.getHeaderGroups().map((headerGroup) => (
<tr className="border-b" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
aria-label={`${row.original.name} 마에스트로 상세 보기`}
className="cursor-pointer border-b transition-colors last:border-b-0 hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
key={row.id}
onClick={() => {
router.push(`/maestros/${row.original.maestroID}`);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(`/maestros/${row.original.maestroID}`);
}
}}
role="link"
tabIndex={0}
>
{row.getVisibleCells().map((cell) => (
<td className="h-12 px-3 align-middle" key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={columns.length}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function getActivateStatusBadgeClass(maestro: MaestroListItem) {
if (
maestro.activateStatus === 2 &&
isBeforeToday(maestro.availableActivateDateTime)
) {
return "border-rose-200 bg-rose-50 text-rose-700";
}
if (maestro.activateStatus === 2) {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (maestro.activateStatus === 100) {
return "border-muted bg-muted text-muted-foreground";
}
return "border-amber-200 bg-amber-50 text-amber-700";
}
function isBeforeToday(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return false;
}
const targetDate = new Date(date);
const today = new Date();
targetDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
return targetDate.getTime() < today.getTime();
}
@@ -0,0 +1,223 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, X } from "lucide-react";
import { useState, useTransition } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { UpgradeRequestListItem } from "@/lib/upgrade-requests";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
getTrialAccountTypeLabel,
} from "@/lib/utils";
type UpgradeRequestsTableProps = {
data: UpgradeRequestListItem[];
};
export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) {
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<HeaderCell>ID</HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroUpgradeID}
>
<BodyCell>
<span className="font-mono text-xs tabular-nums">
{request.maestroUpgradeID}
</span>
</BodyCell>
<BodyCell>
<Link
className={buttonVariants({
variant: "link",
size: "sm",
className: "h-auto p-0 font-medium",
})}
href={`/maestros/${request.maestroID}`}
>
{request.maestroName}
</Link>
<p className="mt-1 text-xs text-muted-foreground">
MaestroID {request.maestroID}
</p>
</BodyCell>
<BodyCell>
{getActivateStatusLabel(request.registeredActivateStatus)}
</BodyCell>
<BodyCell>{getRegisteredAccountLabel(request)}</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.requestedAccountType)}
</BodyCell>
<BodyCell>
<span className="tabular-nums">
{request.additionalPrice.toLocaleString()}
</span>
</BodyCell>
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
<BodyCell>
<UpgradeRequestActions request={request} />
</BodyCell>
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={10}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function UpgradeRequestActions({
request,
}: {
request: UpgradeRequestListItem;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>;
}
async function submitAction(action: "approve" | "reject") {
const actionLabel = action === "approve" ? "승인" : "거절";
if (!window.confirm(`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`)) {
return;
}
setErrorMessage("");
try {
const response = await fetch(
`/api/upgrade-requests/${request.maestroUpgradeID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}
);
const body = (await response.json().catch(() => null)) as {
message?: string;
emailSent?: boolean;
} | null;
if (!response.ok) {
throw new Error(body?.message ?? "업그레이드 신청 처리에 실패했습니다.");
}
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
}
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "업그레이드 신청 처리에 실패했습니다."
);
}
}
return (
<div className="space-y-2">
{isRequested ? (
<div className="flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => void submitAction("approve")}
size="sm"
type="button"
>
<Check className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={isPending}
onClick={() => void submitAction("reject")}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
);
}
function getRegisteredAccountLabel(request: UpgradeRequestListItem) {
if (request.registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
return getTrialAccountTypeLabel(request.registeredAccountType);
}
return getAccountTypeLabel(request.registeredAccountType);
}
function HeaderCell({ children }: { children: React.ReactNode }) {
return (
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
{children}
</th>
);
}
function BodyCell({ children }: { children: React.ReactNode }) {
return <td className="h-12 px-3 align-middle">{children}</td>;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import type { ComponentProps } from "react";
type AutoSubmitSelectProps = ComponentProps<"select">;
export function AutoSubmitSelect({
onChange,
...props
}: AutoSubmitSelectProps) {
return (
<select
{...props}
onChange={(event) => {
onChange?.(event);
event.currentTarget.form?.requestSubmit();
}}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+33
View File
@@ -0,0 +1,33 @@
services:
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
chocoadmin-stage:
build:
context: .
dockerfile: Dockerfile
image: chocoadmin-stage:latest
container_name: chocoadmin-stage
expose:
- "3000"
env_file:
- .env.stage
environment:
NODE_ENV: production
APP_ENV: stage
PORT: 3000
HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks:
- proxy-network
restart: unless-stopped
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
proxy-network:
external: true
+33
View File
@@ -0,0 +1,33 @@
services:
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 stage(chocoadmin-stage)와 겹치면 안 된다.
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
chocoadmin:
build:
context: .
dockerfile: Dockerfile
image: chocoadmin:latest
container_name: chocoadmin
expose:
- "3000"
env_file:
- .env.production
environment:
NODE_ENV: production
APP_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks:
- proxy-network
restart: unless-stopped
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
proxy-network:
external: true
+354
View File
@@ -0,0 +1,354 @@
# 기존 관리자 비즈니스 규칙
작성일: 2026-06-09
## 1. 개요
이 문서는 기존 `mouse-typing` 관리자 기능을 조사해 신규 `chocoadmin`에서 재현해야 할 비즈니스 규칙을 정리한 것이다.
조사 대상:
- 기존 서비스: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
- 관리자 화면: `src/web/admin/`
- 관리자 서버: `src/web/server/admin/`
- 공통 로그 유틸: `src/web/server/lib/maestro_log.php`
- 계정 표시/계산 유틸: `src/web/js/lib/account_info.js`, `src/web/js/maestro_available_date.js`
중요 결론:
- 관리자 비밀번호는 MariaDB `PASSWORD()` 함수로 검증한다.
- 연장 승인은 기존 만료일 기준 1년 연장하되, 만료된 계정은 오늘 기준 1년 연장한다.
- 기존 연장 승인 서버는 날짜를 직접 계산하지 않고 화면에서 전달한 `new_available_date`를 저장한다.
- 업그레이드 승인은 `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`만 변경하고 `PlayerCount`는 변경하지 않는다.
- 기존 관리자에는 연장 취소/업그레이드 거절 버튼이 없다. 승인 처리 중 같은 마에스트로의 다른 요청을 `Status=2`로 닫는 동작만 있다.
- 기존 변경 처리에는 명시적 트랜잭션이 없다.
## 2. 기존 관리자 파일 맵
| 기존 화면 | 서버 파일 | 역할 | 신규 `chocoadmin` 매핑 |
|---|---|---|---|
| `admin/login.html` | `server/admin/login.php` | 관리자 로그인 | `/login`, NextAuth Credentials Provider |
| `admin/admin_all_list.html` | `server/admin/maestro_all_list.php` | 전체 마에스트로 목록 조회 | `/maestros`, `GET /api/maestros` |
| `admin/admin_register_list.html` | `server/admin/maestro_registered_list.php`, `server/admin/register_maestro.php` | 미승인 마에스트로 조회 및 등록 승인 | MVP 외 또는 상세 보조 규칙 |
| `admin/admin_extension_list.html` | `server/admin/maestro_extension_list.php`, `server/admin/extension_maestro.php` | 연장 요청 조회 및 승인 | `/extension-requests`, `PATCH /api/extension-requests/[id]` |
| `admin/admin_upgrade_list.html` | `server/admin/maestro_upgrade_list.php`, `server/admin/upgrade_maestro.php` | 업그레이드 요청 조회 및 승인 | `/upgrade-requests`, `PATCH /api/upgrade-requests/[id]` |
| `admin/admin_expiration_list.html` | 없음 | 정적 골격만 존재, 실제 조회 로직 없음 | MVP 제외 |
| `admin/admin_send_email.html` | `server/mail/send_mail_test.php` | 테스트 메일 발송 화면 | MVP 제외 |
| `admin/admin_header.html` | 없음 | 관리자 메뉴 링크 | 공통 사이드바/헤더 |
## 3. 상태값 코드표
### `maestro.AccountType`
| 값 | 의미 | 정원 | 금액 |
|---:|---|---:|---:|
| 1 | 20명 | 20 | 10,000 |
| 2 | 50명 | 50 | 20,000 |
| 3 | 100명 | 100 | 30,000 |
| 4 | 500명 | 500 | 40,000 |
| 5 | 1,000명 | 1,000 | 50,000 |
| 100 | 학생체험 | - | - |
| 101 | 마에체험 | - | - |
### `maestro.ActivateStatus`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 0 | 미승인 / 입금대기 | 가입 후 관리자 승인 전 |
| 1 | 체험 | 체험 계정 |
| 2 | 활성화 | 유료 활성 계정 |
| 100 | 등록 취소 / 환불 | 등록 취소 또는 환불 상태 |
### `maestro_extension.Status`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 1 | 요청 | 연장 요청 |
| 2 | 취소 | 닫힌 요청 |
| 3 | 업그레이드 적용 | 코드상 공통 표시 문구이나 실제 의미는 연장 적용 |
### `maestro_upgrade.Status`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 1 | 요청 | 업그레이드 요청 |
| 2 | 취소 | 닫힌 요청 |
| 3 | 업그레이드 적용 | 업그레이드 적용 |
## 4. 로그인/관리자 인증 규칙
기존 흐름:
1. `login.html`에서 `admin_name`, `password``server/admin/login.php`로 POST한다.
2. `get_admin_id($adminName)`에서 `admin.Name = ?` 조건으로 관리자 존재 여부를 먼저 확인한다.
3. `login($adminName, $password)`에서 `admin.Name = ? AND admin.Password = PASSWORD(?)` 조건으로 검증한다.
4. 응답 데이터로 `adminID`, `accountType`, `activateStatus`를 반환한다.
실패 조건:
| 조건 | error_code | message |
|---|---|---|
| `Name`에 해당하는 관리자 없음 | `not_registered_id` | 등록되지 않은 서비스 관리자 아이디입니다. |
| 비밀번호 검증 결과 없음 | `no_data` 또는 `wrong_password` | 로그인하지 못했습니다. / 비밀번호가 틀렸습니다. |
| `ActivateStatus === 0` | `not_activated` | 서비스 관리자 계정이 아직 활성화되지 않았습니다. |
신규 구현 규칙:
- 기존 계정 호환을 위해 최초 구현은 MariaDB `PASSWORD()` 함수를 이용해 검증한다.
- NextAuth 세션에는 최소 `adminID`, `accountType`, `activateStatus`를 포함한다.
- `ActivateStatus = 0` 관리자는 로그인시키지 않는다.
- `PASSWORD()`는 보안상 낡은 방식이므로, 해시 업그레이드는 별도 마이그레이션 과제로 분리한다.
## 5. 마에스트로 목록 조회 규칙
### 전체 목록
기존 서버: `server/admin/maestro_all_list.php`
검색어가 없을 때:
- 테이블: `maestro`
- 컬럼: `MaestroID`, `Name`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `AcceptClausesDateTime`, `PlayerCount`
- 정렬: `MaestroID DESC`
검색어가 있을 때:
- 조건: `Name LIKE CONCAT('%', ?, '%')`
- 정렬: `MaestroID DESC`
- 주의: 기존 검색 쿼리는 `AvailableActivateDateTime`을 조회하지 않는데 화면은 해당 값을 사용한다. 신규 구현에서는 전체/검색 결과 모두 동일 컬럼을 반환해야 한다.
기존 화면 표시:
- 상태 텍스트는 `accountStatus(accountType, activateStatus)`로 계산한다.
- `AvailableActivateDateTime``0`으로 시작하면 빈 문자열로 표시한다.
- 비활성화 버튼은 disabled 상태이며 동작하지 않는다.
신규 구현 규칙:
- `Name`, `Email`, `MaestroID` 검색을 지원하되 기존 기본 정렬은 `MaestroID DESC`로 시작한다.
- 전체/검색 결과의 반환 컬럼 차이를 만들지 않는다.
### 미승인 등록 목록
기존 서버: `server/admin/maestro_registered_list.php`
- 테이블: `maestro`
- 조건: `ActivateStatus = 0`
- 검색 조건: `Name LIKE CONCAT('%', ?, '%') AND ActivateStatus = 0`
- 컬럼: `MaestroID`, `Name`, `AccountType`, `AcceptClausesDateTime`
- 정렬: `MaestroID DESC`
## 6. 등록/활성화 처리 규칙
기존 서버: `server/admin/register_maestro.php`
처리 순서:
1. `maestro.ActivateStatus = 1`로 변경한다.
2. `maestro.AvailableActivateDateTime = '2022-07-31 23:59:59'`로 변경한다.
3. `player`에 테스트 플레이어 `Name='마에스트로'`, `EnterCode=''`, `AccountType=1`을 추가한다.
4. 추가된 테스트 플레이어를 `maestro.MaestroTestID`에 저장한다.
5. `app.Status = 1`인 앱을 모두 `active_app`에 추가한다.
6. 샘플 학생 4명을 `player.AccountType = 0`으로 추가한다.
7. `player.AccountType = 0` 학생 수를 세어 `maestro.PlayerCount`에 저장한다.
8. 등록 완료 메일을 발송한다.
9. `maestro_log``register_maestro` 로그를 기록한다.
주의:
- 코드에는 `DATE_ADD(NOW(), INTERVAL 1 YEAR)``DATE_ADD(NOW(), INTERVAL 14 DAY)`가 주석으로 남아 있지만 실제 실행 쿼리는 고정일 `2022-07-31 23:59:59`다.
- 현재 MVP의 핵심 변경 기능은 연장/업그레이드이므로 등록 승인은 별도 범위로 둘 수 있다.
## 7. 연장 신청 조회/승인 규칙
### 요청 생성
기존 서버: `server/maestro/request_extension_maestro.php`
- `maestro_extension``AccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
- INSERT 후 `MaestroID` 기준 전체 연장 요청 수가 1개 이상인지 단순 확인한다.
- 입금 안내 메일을 발송한다.
### 관리자 목록 조회
기존 서버: `server/admin/maestro_extension_list.php`
검색어가 없을 때:
- 테이블: `maestro_extension E`, `maestro M`
- 조건: `E.Status < 2 AND E.MaestroID = M.MaestroID`
- 컬럼: `E.MaestroExtensionID`, `E.MaestroID`, `M.Name`, `E.AccountType`, `E.RequestedDateTime`, `M.AvailableActivateDateTime`, `E.Status`
- 정렬: `E.MaestroExtensionID DESC`
검색어가 있을 때:
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND E.MaestroID = M.MaestroID`
- 주의: 검색 쿼리에는 `E.Status < 2` 조건이 없다. 신규 구현에서는 기본 정책에 맞게 상태 필터를 명시적으로 제공해야 한다.
### 관리자 승인
기존 화면: `admin/admin_extension_list.html`
- `availableActivateDateTime`의 날짜 부분을 기준으로 새 만료일을 계산한다.
- 계산 유틸: `src/web/js/maestro_available_date.js`
- 현재 만료일이 지났으면 오늘 기준 1년 후 날짜를 사용한다.
- 현재 만료일이 지나지 않았으면 기존 만료일 기준 1년 후 날짜를 사용한다.
- 서버에는 `new_available_date` 문자열을 POST한다.
기존 서버: `server/admin/extension_maestro.php`
처리 순서:
1. `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime = DATE_FORMAT(?, '%Y-%m-%d')`로 변경한다.
2. 같은 `MaestroID``maestro_extension.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
3. 선택한 `MaestroExtensionID` 요청을 `Status = 3`으로 변경한다.
4. 연장 완료 메일을 발송한다.
5. `maestro_log``extension_maestro` 로그를 기록한다.
로그:
- `Type`: `extension_maestro`
- `Remark`: `{maestro_name}({registered_account_type})`
신규 구현 규칙:
- 날짜 계산은 클라이언트가 아니라 서버에서 수행한다.
- 기존 규칙과 동일하게 만료 전이면 기존 만료일 기준 +1년, 만료 후면 현재일 기준 +1년으로 계산한다.
- `AvailableActivateDateTime` 저장 시 기존 서버는 날짜만 저장하므로 시간은 DB 기본 파싱 결과를 고려해야 한다. 신규 구현에서는 일관성을 위해 `YYYY-MM-DD 00:00:00` 또는 기존 호환 형식을 명시한다.
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
- 트랜잭션 안에서 `maestro`, `maestro_extension`, `maestro_log`를 함께 처리한다.
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
## 8. 업그레이드 신청 조회/승인 규칙
### 요청 생성
기존 서버: `server/maestro/request_upgrade_maestro.php`
- `maestro_upgrade``RegisteredActivateStatus`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
- INSERT 후 `MaestroID` 기준 전체 업그레이드 요청 수가 1개 이상인지 단순 확인한다.
- 체험 계정이면 체험 업그레이드 입금 안내 메일, 그 외에는 일반 업그레이드 입금 안내 메일을 발송한다.
### 관리자 목록 조회
기존 서버: `server/admin/maestro_upgrade_list.php`
검색어가 없을 때:
- 테이블: `maestro_upgrade U`, `maestro M`
- 조건: `U.Status < 2 AND U.MaestroID = M.MaestroID`
- 컬럼: `U.MaestroUpgradeID`, `U.MaestroID`, `M.Name`, `U.RegisteredActivateStatus`, `U.RegisteredAccountType`, `U.RequestedAccountType`, `U.RequestedDateTime`, `M.AvailableActivateDateTime`, `U.Status`
- 정렬: `U.MaestroUpgradeID DESC`
검색어가 있을 때:
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND U.MaestroID = M.MaestroID`
- 주의: 검색 쿼리에는 `U.Status < 2` 조건이 없다. 신규 구현에서는 상태 필터를 명시적으로 제공해야 한다.
### 관리자 승인
기존 화면: `admin/admin_upgrade_list.html`
- `Status = 1`인 요청만 승인 버튼을 표시한다.
- 체험 계정(`RegisteredActivateStatus = 1`)은 기존 요금제를 `trialAccountType`으로 표시하고 추가 금액은 요청 요금제 전체 금액으로 계산한다.
- 그 외 계정은 추가 금액을 `RequestedAccountType` 금액 - `RegisteredAccountType` 금액으로 계산한다.
기존 서버: `server/admin/upgrade_maestro.php`
처리 순서:
1. `maestro.AccountType = upgrade_account_type`으로 변경한다.
2. `maestro.ActivateStatus = 2`로 변경한다.
3. `maestro.AvailableActivateDateTime = DATE_ADD(NOW(), INTERVAL 1 YEAR)`로 변경한다.
4. 같은 `MaestroID``maestro_upgrade.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
5. 선택한 `MaestroUpgradeID` 요청을 `Status = 3`으로 변경한다.
6. 체험 계정이면 체험 업그레이드 완료 메일, 그 외에는 일반 업그레이드 완료 메일을 발송한다.
7. `maestro_log``upgrade_maestro` 로그를 기록한다.
로그:
- `Type`: `upgrade_maestro`
- `Remark`: `{registered_account_type} -> {upgrade_account_type}`
확정 사항:
- `PlayerCount`는 변경하지 않는다.
- `AvailableActivateDateTime`은 기존 만료일 기준이 아니라 승인 시점 `NOW()` 기준 1년 후로 설정한다.
신규 구현 규칙:
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
- 트랜잭션 안에서 `maestro`, `maestro_upgrade`, `maestro_log`를 함께 처리한다.
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
- `PlayerCount`는 업그레이드 승인에서 변경하지 않는다.
## 9. 취소/거절 규칙
기존 관리자 UI/서버에서 확인된 사실:
- 연장 신청에 대한 별도 취소 버튼이나 취소 API는 없다.
- 업그레이드 신청에 대한 별도 거절 버튼이나 거절 API는 없다.
- `Status = 2`는 승인 처리 중 같은 마에스트로의 다른 미처리 요청을 닫기 위해 사용된다.
- 화면 유틸 `status()``Status = 2`를 "취소"로 표시하지만, 관리자가 명시적으로 취소/거절하는 흐름은 구현되어 있지 않다.
신규 구현 판단:
- `docs/plan/project-plan.md`의 취소/거절 기능은 기존 관리자 재현이 아니라 신규 보완 기능이다.
- 취소/거절 API를 만들 경우 `Status = 1`인 단일 요청만 `Status = 2`로 변경하고 `maestro_log`에 별도 로그를 남기는 방식으로 정의해야 한다.
- 기존 `maestro_log.Type`에는 취소/거절 전용 타입이 없으므로 신규 타입을 도입할지 기존 타입에 `Remark`로 표현할지 결정이 필요하다.
권장 신규 로그 타입:
- `cancel_extension_maestro`
- `reject_upgrade_maestro`
## 10. `maestro_log` 기록 규칙
공통 함수: `src/web/server/lib/maestro_log.php`
```sql
INSERT INTO maestro_log (Type, MaestroID, LogDateTime, Remark)
VALUES (?, ?, NOW(), ?)
```
확인된 로그 타입:
| Type | 발생 위치 | Remark 형식 |
|---|---|---|
| `add_maestro` | `server/maestro/add_maestro.php` | `{maestro_name} / {email} / {account_type}` |
| `register_maestro` | `server/admin/register_maestro.php` | `{name} / {email}` |
| `extension_maestro` | `server/admin/extension_maestro.php` | `{maestro_name}({registered_account_type})` |
| `upgrade_maestro` | `server/admin/upgrade_maestro.php` | `{registered_account_type} -> {upgrade_account_type}` |
| `update_maestro_name` | `server/maestro/update_maestro_info.php` | `{previous_name} -> {new_name}` |
| `update_maestro_email` | `server/maestro/update_maestro_info.php` | `{previous_email} -> {new_email}` |
확인 결과:
- `update_maestro` 타입은 코드 검색에서 확인되지 않았다.
- 로그에는 관리자 ID나 관리자 이름이 포함되지 않는다.
- 로그 시간은 DB 서버의 `NOW()`를 사용한다.
- `Remark` 컬럼은 `char(100)`이므로 신규 구현에서도 길이 초과에 주의한다.
## 11. 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
- 운영 DB 스키마는 변경하지 않는다.
- 관리자 로그인은 기존 `admin.Name``admin.Password = PASSWORD(?)` 호환 검증을 지원한다.
- 모든 변경 API는 서버에서 현재 상태를 다시 조회하고 `Status = 1`인지 확인한다.
- 승인/취소/거절 같은 변경 작업은 트랜잭션으로 묶는다.
- 연장 승인은 서버에서 새 만료일을 계산한다.
- 업그레이드 승인은 `PlayerCount`를 변경하지 않는다.
- 연장/업그레이드 승인 시 같은 마에스트로의 다른 요청을 `Status = 2`로 닫고 선택 요청을 `Status = 3`으로 적용한다.
- 기존 목록 검색 쿼리의 상태 필터 누락은 그대로 복제하지 말고 명시적 필터로 보완한다.
- 메일 발송은 트랜잭션 커밋 이후 수행하는 것이 안전하다. 기존 코드는 DB 변경 후 메일을 발송한다.
## 12. 기존 코드에는 없지만 신규 구현에서 보완할 사항
- 명시적 트랜잭션: 기존 관리자 변경 로직에는 `BEGIN`, `COMMIT`, `ROLLBACK`이 없다.
- 중복 처리 방지: 기존 승인 서버는 요청의 현재 `Status`를 먼저 검증하지 않는다.
- 서버 사이드 날짜 계산: 기존 연장 승인은 클라이언트 계산값을 신뢰한다.
- 검색 필터 일관성: 연장/업그레이드 검색 쿼리에 `Status < 2` 조건이 빠져 있다.
- 목록 컬럼 일관성: 전체 마에스트로 검색 쿼리에 `AvailableActivateDateTime`이 빠져 있다.
- 취소/거절 정의: 기존에는 별도 액션이 없으므로 신규 기능으로 명세화해야 한다.
- 감사성 강화: 기존 `maestro_log`에는 관리자 식별자가 없으므로 세션 기반 감사 로그가 필요하면 별도 설계가 필요하다. 단, DB 스키마 변경 없이 구현하려면 `Remark`에 관리자 정보를 포함하는 방식만 가능하다.
+223
View File
@@ -0,0 +1,223 @@
# chocoadmin Deployment
## 1. Environment Files
Create environment files on the Synology NAS. Do not commit real `.env.*` files.
Stage file path:
```bash
/volume1/docker/service/jinaju/chocoadmin/.env.stage
```
Stage example:
```bash
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
AUTH_SECRET="replace-with-stage-secret"
NEXTAUTH_SECRET="replace-with-stage-secret"
AUTH_URL="https://chocoadmin-stage.jisangs.com"
NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
```
Production file path:
```bash
/volume1/docker/service/jinaju/chocoadmin/.env.production
```
Production example:
```bash
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@chocomae.jinaju.com:3306/chocomae"
AUTH_SECRET="replace-with-production-secret"
NEXTAUTH_SECRET="replace-with-production-secret"
AUTH_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.
`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.
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
Generate a secret on the NAS:
```bash
openssl rand -base64 32
```
## 2. Synology Stage Deployment
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
```bash
cd /volume1/docker/service/jinaju/chocoadmin-stage
git pull --ff-only
```
Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
Verify or create the network:
```bash
docker network inspect proxy-network >/dev/null 2>&1 || docker network create proxy-network
```
Start or update stage:
```bash
docker compose -f docker-compose.stage.yml up -d --build
docker compose -f docker-compose.stage.yml ps
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.
After redeployment, reload NPM to flush its upstream DNS cache:
```bash
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.
Nginx Proxy Manager settings:
- Scheme: `http`
- Forward Hostname / IP: `chocoadmin-stage`
- Forward Port: `3000`
- Websockets Support: enabled
- SSL: enabled
- Force SSL: enabled
Stage URL:
```text
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.
## 3. Production Deployment
Production uses `docker-compose.yml` and reads `.env.production` by default:
```bash
cd /volume1/docker/service/jinaju/chocoadmin
git pull --ff-only
docker compose up -d --build
docker compose ps
docker compose logs -f chocoadmin
```
After redeployment, reload NPM:
```bash
docker exec npm nginx -s reload
```
Nginx Proxy Manager settings:
- Scheme: `http`
- Forward Hostname / IP: `chocoadmin`
- Forward Port: `3000`
- Websockets Support: enabled
- SSL: enabled
- Force SSL: enabled
Production URL:
```text
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`.
## 4. AWS EC2 Security Group
Allow MariaDB only from the Synology NAS public IP.
- Type: `MYSQL/Aurora`
- Protocol: `TCP`
- Port: `3306`
- Source: `NAS_PUBLIC_IP/32`
- Description: `chocoadmin Synology NAS`
Do not open `3306` to `0.0.0.0/0`.
## 5. Read-Only Rehearsal
Before enabling approval/rejection operations against production DB:
1. Create or use a DB account with read-only permissions.
2. Set `DATABASE_URL` in `.env.production` to that read-only account.
3. Start the container.
4. Verify login, maestro list, extension request list, and upgrade request list.
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
## 6. Smoke Checks
After each deployment:
```bash
docker compose -f docker-compose.stage.yml ps # stage
docker compose ps # production
```
Verify these routes in the browser:
- `/login`
- `/maestros`
- `/extension-requests`
- `/upgrade-requests`
## 7. Troubleshooting
### ERR_TOO_MANY_REDIRECTS after login
Middleware redirects to `/login`, login page redirects back to `/` — infinite loop. Work through this checklist in order:
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
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.
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.
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.
5. **Clear browser cookies** for the domain, then test in a private window.
6. **Recreate the container** after changing `.env.*`.
### Production and stage traffic mixing
Symptom: logging into `chocoadmin.jinaju.com` shows the stage UI, or requests hit both containers interchangeably.
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.
Check the network:
```bash
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.
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.
### Failed to find Server Action after redeployment
Symptom: clicking a button (e.g. logout) returns a 404 or `Failed to find Server Action` error after deploying a new build.
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.
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.
### Container starts but immediately exits
```bash
docker compose logs chocoadmin
```
Common causes:
- Missing `DATABASE_URL` or malformed connection string.
- `AUTH_SECRET` not set — NextAuth throws on startup.
- 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.
+285
View File
@@ -0,0 +1,285 @@
# Phase 0 작업 계획
## 1. 목적
`docs/plan/project-plan.md``9. 개발 단계 계획 > Phase 0. 기존 관리자 코드 조사`를 수행하기 위한 실행 계획이다.
Phase 0의 목표는 기존 `mouse-typing` 관리자 기능의 비즈니스 규칙을 확인하고, 신규 `chocoadmin`에서 동일하게 재현해야 할 규칙을 `docs/business-rules.md`에 문서화하는 것이다.
## 2. 조사 대상
기존 서비스 경로:
- `/Users/jisangs/work/jinaju/git/web/mouse-typing`
우선 조사 디렉터리:
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin/`
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/`
주요 서버 파일:
- `login.php`
- `maestro_all_list.php`
- `maestro_registered_list.php`
- `maestro_extension_list.php`
- `extension_maestro.php`
- `maestro_upgrade_list.php`
- `upgrade_maestro.php`
- `register_maestro.php`
- `activate_stage.php`
주요 화면 파일:
- `login.html`
- `admin_all_list.html`
- `admin_register_list.html`
- `admin_extension_list.html`
- `admin_upgrade_list.html`
- `admin_expiration_list.html`
- `admin_header.html`
## 3. 확인해야 할 핵심 질문
### 3-1. 관리자 라우트와 기능 구조
- 관리자 화면별 HTML 파일과 서버 PHP 파일의 호출 관계를 확인한다.
- 각 화면의 목록 조회 조건, 검색 조건, 정렬 기준, 상태 필터를 확인한다.
- 신규 `chocoadmin`의 화면/API 구조와 직접 매핑 가능한 기능 목록을 정리한다.
산출물:
- 기존 화면 -> 기존 서버 파일 -> 신규 화면/API 매핑표
### 3-2. 연장 승인 규칙
- `extension_maestro.php`에서 연장 승인 처리 흐름을 확인한다.
- `maestro_extension.Status` 변경값과 변경 조건을 확인한다.
- `maestro.AvailableActivateDateTime` 계산 방식을 확인한다.
- `maestro.ActivateStatus` 변경 여부와 값을 확인한다.
- 실패/중복 처리 조건이 있는지 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- 계정 유형별 차이 없이 1년 연장
검증 기준:
- 기존 코드에서 날짜 계산 로직이 `+1 year`, `+12 month`, 고정 일수 중 무엇인지 확인한다.
- 만료일이 과거인 경우 기준일이 현재일인지 기존 만료일인지 확인한다.
### 3-3. 업그레이드 승인 규칙
- `upgrade_maestro.php`에서 업그레이드 승인 처리 흐름을 확인한다.
- `maestro_upgrade.Status` 변경값과 변경 조건을 확인한다.
- `maestro.AccountType` 변경 기준을 확인한다.
- `maestro.PlayerCount`를 즉시 변경하는지 확인한다.
- 거절 처리 시 변경되는 테이블과 로그를 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- `AccountType`만 변경
- `PlayerCount`는 변경하지 않음
검증 기준:
- 승인 SQL에 `PlayerCount` 업데이트가 포함되는지 확인한다.
- 목록 조회 시 `RegisteredAccountType`, `RequestedAccountType`을 어떻게 표시하는지 확인한다.
### 3-4. 관리자 비밀번호 검증 방식
- `login.php`에서 `admin.Password` 검증 SQL을 확인한다.
- MariaDB `PASSWORD()` 함수 사용 여부를 확인한다.
- 세션에 저장되는 관리자 식별자와 권한 정보를 확인한다.
- 실패 응답 형식과 비활성 관리자 처리 여부를 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- MariaDB `PASSWORD()` 함수로 저장/검증
검증 기준:
- `WHERE Password = PASSWORD(?)` 형태인지 확인한다.
- `ActivateStatus`, `AccountType` 같은 추가 조건이 있는지 확인한다.
### 3-5. `maestro_log` 기록 형식
- 관리자 변경 액션이 `maestro_log`에 기록되는 모든 지점을 찾는다.
- `Type` 값 목록을 확인한다.
- `Remark` 문자열 형식과 포함 정보, 날짜 형식을 확인한다.
- 로그 작성 시 관리자 ID/이름이 포함되는지 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 `Type` 후보:
- `add_maestro`
- `update_maestro`
- `register_maestro`
- `update_maestro_name`
- `extension_maestro`
- `upgrade_maestro`
- `update_maestro_email`
검증 기준:
- 중복 기재된 값과 실제 미사용 값을 정리한다.
- 각 액션별 `Remark` 예시를 코드 기준으로 문서화한다.
## 4. 작업 순서
### Step 1. 파일 인벤토리 작성
- 관리자 HTML/PHP 파일 목록을 확정한다.
- 각 파일의 역할을 한 줄로 요약한다.
- AJAX 호출 URL, form action, query parameter를 찾는다.
완료 기준:
- `docs/business-rules.md`에 "기존 관리자 파일 맵" 섹션 초안 작성
### Step 2. 로그인 로직 조사
- `login.html``login.php`를 연결해 인증 흐름을 추적한다.
- DB 조회 조건, 비밀번호 검증 방식, 세션 키를 기록한다.
- 신규 NextAuth Credentials Provider에서 재현해야 할 조건을 정리한다.
완료 기준:
- `admin.Password` 검증 방식 확정
- 로그인 성공/실패 조건 문서화
### Step 3. 목록 조회 로직 조사
- 전체 마에스트로 목록, 등록 신청 목록, 연장 신청 목록, 업그레이드 신청 목록 SQL을 확인한다.
- 조인 테이블, 상태 필터, 기본 정렬, 표시 컬럼을 정리한다.
- 신규 API 쿼리 설계에 필요한 컬럼 목록을 분리한다.
완료 기준:
- 신규 목록 API별 필수 SQL 조건 문서화
### Step 4. 등록/활성화 처리 조사
- `register_maestro.php``activate_stage.php`의 상태 변경 로직을 확인한다.
- `maestro.ActivateStatus`, `AvailableActivateDateTime`, `maestro_log` 처리 규칙을 기록한다.
- MVP 범위에 직접 포함되지 않더라도 상세 화면과 로그 해석에 필요한 규칙을 정리한다.
완료 기준:
- 마에스트로 상태값 변경 규칙 문서화
### Step 5. 연장 승인/취소 처리 조사
- `maestro_extension_list.php`에서 목록 조회 조건을 확인한다.
- `extension_maestro.php`에서 승인/취소 처리 SQL을 순서대로 기록한다.
- 트랜잭션 사용 여부와 중복 처리 방지 조건을 확인한다.
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
완료 기준:
- 연장 승인 시 날짜 계산 기준 확정
- 연장 취소 시 상태값과 로그 형식 확정
### Step 6. 업그레이드 승인/거절 처리 조사
- `maestro_upgrade_list.php`에서 목록 조회 조건을 확인한다.
- `upgrade_maestro.php`에서 승인/거절 처리 SQL을 순서대로 기록한다.
- `AccountType`, `PlayerCount`, `maestro_upgrade.Status`, `maestro_log` 변경 규칙을 확인한다.
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
완료 기준:
- 업그레이드 승인 시 변경 컬럼 확정
- 업그레이드 거절 시 상태값과 로그 형식 확정
### Step 7. 비즈니스 규칙 문서 작성
`docs/business-rules.md`를 생성하고 다음 구조로 정리한다.
- 개요
- 기존 관리자 파일 맵
- 상태값 코드표
- 로그인/관리자 인증 규칙
- 마에스트로 목록 조회 규칙
- 등록/활성화 처리 규칙
- 연장 신청 조회/승인/취소 규칙
- 업그레이드 신청 조회/승인/거절 규칙
- `maestro_log` 기록 규칙
- 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
- 기존 코드에는 없지만 신규 구현에서 보완할 사항
완료 기준:
- Phase 1 이후 구현자가 기존 PHP 코드를 다시 열지 않아도 핵심 규칙을 확인할 수 있어야 한다.
## 5. 조사 방법
권장 검색 명령:
```bash
rg -n "maestro_log|extension_maestro|upgrade_maestro|PASSWORD|AvailableActivateDateTime|PlayerCount|ActivateStatus|AccountType|Status" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin
```
파일별 상세 확인:
```bash
sed -n '1,240p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/login.php
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php
```
HTML 호출부 확인:
```bash
rg -n "ajax|fetch|XMLHttpRequest|form|server/admin|extension_maestro|upgrade_maestro|login.php" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin
```
## 6. 완료 산출물
Phase 0 완료 시 저장소에 다음 문서가 있어야 한다.
- `docs/business-rules.md`
- 기존 관리자 비즈니스 규칙의 최종 문서
- `docs/phase/phase0.md`
- Phase 0 수행 계획 문서
필요하면 다음 보조 자료를 `docs/` 아래에 추가한다.
- `docs/admin-file-map.md`
- 파일 매핑이 길어질 경우 분리
- `docs/sql-notes.md`
- 기존 SQL 원문 기록이 길어질 경우 분리
## 7. 완료 체크리스트
- [x] `src/web/admin/` 화면 파일과 `src/web/server/admin/` 처리 파일의 연결 관계를 정리했다.
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식을 코드 기준으로 확정했다.
- [x] 연장 승인/취소 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부를 코드 기준으로 확정했다.
- [x] 업그레이드 승인/거절 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
- [x] `admin.Password` 저장/검증 방식을 코드 기준으로 확정했다.
- [x] `maestro_log.Type` 값과 `Remark` 형식을 액션별로 정리했다.
- [x] 기존 코드에 트랜잭션/중복 처리 방지가 부족한 지점을 신규 구현 보완사항으로 표시했다.
- [x] 조사 결과를 `docs/business-rules.md`에 문서화했다.
- [x] `docs/plan/project-plan.md`의 Phase 0 체크리스트를 완료 처리할 수 있는 상태가 되었다.
## 8. 리스크와 주의사항
- 기존 PHP 코드가 트랜잭션 없이 여러 SQL을 순차 실행할 수 있다. 신규 서비스는 기존 결과는 동일하게 유지하되, 변경 액션은 트랜잭션으로 보완한다.
- 기존 코드에 중복 처리 방지 조건이 없더라도 신규 API는 `Status = 1` 조건 확인 후 변경해야 한다.
- MariaDB `PASSWORD()` 함수는 최신 인증용 해시로 적합하지 않다. Phase 0에서는 기존 검증 방식을 정확히 재현하고, 해시 업그레이드는 별도 후속 과제로 분리한다.
- 운영 DB 스키마는 임의 변경하지 않는다.
- 문서화 시 추측과 코드로 확인된 사실을 구분해 적는다.
## 9. 예상 일정
| 순서 | 작업 | 예상 소요 |
|---|---|---:|
| 1 | 파일 인벤토리와 호출 관계 정리 | 30분 |
| 2 | 로그인/인증 로직 조사 | 20분 |
| 3 | 목록 조회 SQL 조사 | 40분 |
| 4 | 등록/활성화 처리 조사 | 30분 |
| 5 | 연장 승인/취소 처리 조사 | 40분 |
| 6 | 업그레이드 승인/거절 처리 조사 | 40분 |
| 7 | `docs/business-rules.md` 작성 및 검토 | 60분 |
총 예상 소요: 약 4시간
+176
View File
@@ -0,0 +1,176 @@
# Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
## 목표
`/maestros/[maestroId]` 상세 화면에서 마에스트로 정보를 직접 수정할 수 있는 폼을 추가하고, 학생 목록을 서버 사이드 페이지네이션과 이름 검색이 가능한 형태로 완성한다.
---
## 1. 마에스트로 상세 화면 수정 폼 구현
### 사전 조사 결과 (기존 mouse-typing 코드 확인 완료)
| 조사 항목 | 파일 위치 | 결과 |
|---|---|---|
| 이름 중복 검사 기준 | `server/maestro/update_maestro_info.php` | **중복 검사 없음** — 이름을 그대로 UPDATE. chocoadmin에서 신규 추가. |
| 이메일 변경 처리 | `server/maestro/update_maestro_info.php` | 변경 시 이메일 안내 메일 발송 + `maestro_log` 기록. 메일은 Phase 11에서 구현. |
| 입장코드 수정 컬럼 | `server/maestro/set_allow_edit_entercode.php` | `maestro.AllowEditEnterCode`: `0`(비허용) / `1`(허용). 로그 기록 없음. |
| 상태/계정유형 변경 로그 | `server/admin/` 전체 | 기존 관리자에서 상태/계정유형/사용가능일을 직접 수정하는 기능 없음. 신규 기능이므로 신규 로그 타입 정의. |
정의한 신규 로그 타입 (기존 코드에 없음):
| Type | 대상 필드 | Remark 형식 |
|---|---|---|
| `update_maestro_status` | `ActivateStatus` | `{prev} -> {new}` (숫자) |
| `update_maestro_account_type` | `AccountType` | `{prev} -> {new}` (숫자) |
| `update_maestro_available_date` | `AvailableActivateDateTime` | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
| `update_maestro_allow_enter_code` | `AllowEditEnterCode` | `{prev} -> {new}` (0 or 1) |
### API 구현: `PATCH /api/maestros/[id]` ✅
파일: `app/api/maestros/[id]/route.ts` (기존 GET과 동일 파일에 PATCH 추가)
서비스 레이어: `lib/maestros.ts``updateMaestroSchema`, `UpdateMaestroPayload`, `updateMaestro()` 추가
**요청 스키마 (Zod)**
| 필드 | 타입 | 규칙 |
|---|---|---|
| `name` | `string` | 1자 이상, trim |
| `email` | `string` | 이메일 형식 |
| `activateStatus` | `0 \| 1 \| 2 \| 100` | `ACTIVATE_STATUSES` 상수 |
| `accountType` | `1 \| 2 \| 3 \| 4 \| 5` | 유료 타입만 허용 (`PaidAccountType`) |
| `availableActivateDateTime` | `string` | ISO datetime 또는 `YYYY-MM-DD` 형식 |
| `allowEditEnterCode` | `0 \| 1` | 정수 |
모든 필드는 optional — 전달된 필드만 업데이트한다.
**처리 순서**
1. `withApiHandler`로 인증 자동 검사
2. 대상 maestro 존재 확인 (`findUnique`) — 없으면 404
3. `name`이 포함된 경우: 동일 이름의 다른 마에스트로 존재 여부 확인 — 있으면 409
4. 변경 필드 추출 (전달값 vs 현재값 비교, 동일한 값이면 변경 대상에서 제외)
5. 변경 필드가 없으면 200 + `{ changed: false }` 반환 (DB 업데이트 및 로그 기록 생략)
6. 트랜잭션: `maestro` 업데이트 + `maestro_log` INSERT (변경된 필드별로 각각 로그 기록)
7. 성공 응답: 200 + `{ changed: true }`
**응답 정책**
| 상황 | HTTP | 비고 |
|---|---|---|
| 변경 없음 | 200 | `{ changed: false }` |
| 성공 | 200 | `{ changed: true }` |
| 이름 중복 | 409 | 자기 자신은 중복으로 처리하지 않음 |
| 존재하지 않는 마에스트로 | 404 | |
| 유효하지 않은 값 | 400 | Zod 파싱 실패 |
| 인증 없음 | 401 | `withApiHandler` 처리 |
### UI 구현: 수정 폼 ✅
파일: `app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx` (신규 Client Component)
현재 상세 페이지(`page.tsx`)의 "기본 정보" 섹션을 읽기 전용 표시에서 편집 가능한 폼으로 교체한다.
**필드 구성**
| 필드 | 컴포넌트 | 비고 |
|---|---|---|
| 이름 | `<Input type="text">` | |
| 이메일 | `<Input type="email">` | |
| 상태 | `<Select>` | `ACTIVATE_STATUS_LABELS` 기반 |
| 계정유형 | `<Select>` | `ACCOUNT_TYPE_LABELS` 기반, 유료 타입만 |
| 사용 가능일 | `<Input type="datetime-local">` | |
| 입장코드 수정 | `<Select>` | 허용(1) / 비허용(0) |
**제출 흐름**
1. 저장 버튼 클릭 → 확인 다이얼로그 표시
2. 확인 → `PATCH /api/maestros/[id]` 호출, 저장 버튼 비활성화
3. 성공 → 성공 토스트 + `router.refresh()`로 화면 갱신
4. 실패 → 실패 토스트 (이름 중복 409 응답 시 "이미 사용 중인 이름입니다." 별도 메시지)
---
## 2. 학생 목록 구현
### API 구현: `GET /api/maestros/[id]/students` ✅
파일: `app/api/maestros/[id]/students/route.ts` (신규)
서비스 레이어: `lib/maestros.ts``studentSearchSchema`, `MaestroStudentsResult`, `getMaestroStudents()` 추가
**쿼리 파라미터**
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|------|---|
| `page` | number | `1` | 페이지 번호 |
| `pageSize` | `10 \| 20 \| 50` | `10` | 페이지 크기 |
| `q` | string | `""` | 이름 검색 (`LIKE %q%`) |
**응답 형식**
```ts
{
items: Array<{
playerID: number;
name: string;
enterCode: string;
accountType: number | null;
}>;
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
}
```
**처리 순서**
1. `withApiHandler`로 인증 검사
2. maestro 존재 확인 — 없으면 404
3. 쿼리 파라미터 Zod 파싱
4. `player` 테이블에서 `MaestroID` 조건 + 이름 `LIKE` 검색 + 페이지네이션 조회
5. 정렬: `PlayerID DESC`
### UI 구현: 학생 목록 ✅
현재 "학생 목록 요약" 섹션(최근 20명 고정)을 서버 사이드 페이지네이션 목록으로 교체한다.
파일: `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` (신규 Client Component)
**구성 요소**
- 이름 검색 입력 (`<Input>`) + 검색 버튼
- 전체 학생 수 표시 (`전체 N명`)
- 학생 목록 테이블 (`PlayerID`, `이름`, `입장코드`)
- 페이지네이션 컨트롤 — 마에스트로 목록과 동일한 슬라이딩 윈도우 형식 (현재 페이지 ±2, 처음/끝 이동, ±5 그룹 이동)
- 빈 목록 상태: "등록된 학생이 없습니다."
- 검색 결과 없음 상태: "검색 결과가 없습니다."
**동작**
- 검색어 입력 후 Enter 또는 검색 버튼 클릭으로 조회 (실시간 debounce 없음)
- 검색어 변경 시 page를 1로 초기화
- 페이지 이동은 URL query string 없이 컴포넌트 state로 관리
---
## 3. 검증 ✅
| 항목 | 방법 | 기대 결과 | 실행 결과 |
|---|---|---|---|
| 이름 중복 저장 실패 | 다른 마에스트로와 동일한 이름으로 저장 시도 | 409, "이미 사용 중인 이름입니다." | ✅ 409 + `{"message":"이미 사용 중인 이름입니다."}` |
| 이름 자기 자신과 동일 | 현재 이름 그대로 저장 | 변경 없음 처리 또는 중복 아님으로 정상 처리 | ✅ 200 + `{"changed":false}` |
| 정보 수정 성공 후 DB 반영 | 이름 / 이메일 / 상태 / 계정유형 / 사용 가능일 / 입장코드 수정 변경 후 DB 직접 조회 | 변경값 반영 확인 | ✅ 모든 필드 DB 반영 확인 |
| maestro_log 기록 확인 | 정보 수정 성공 후 `maestro_log` 조회 | 변경 필드별 `Type`, `Remark` 형식이 §1 정의와 일치 | ✅ 6개 Type 모두 정확한 형식으로 기록됨 |
| 변경 없는 저장 | 현재 값 그대로 저장 시도 | `{ changed: false }` 응답, `maestro_log` 미기록 | ✅ 200 + `{"changed":false}`, 로그 없음 |
| 학생 목록 페이지네이션 | 학생 수가 pageSize 초과인 마에스트로에서 다음 페이지 이동 | 정확한 레코드 표시, `totalCount` 일치 | ✅ 823명 마에스트로: totalPages=83, page=2 정상 |
| 학생 목록 이름 검색 | 존재하는 이름 일부로 검색 | 해당 학생만 필터링 | ✅ `q=최건` → totalCount=1, 해당 학생만 반환 |
| 학생 목록 검색 결과 없음 | 존재하지 않는 이름으로 검색 | 빈 상태 메시지 표시 | ✅ totalCount=0, items=[] |
| 비인증 접근 차단 | 세션 없이 `PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students` 호출 | 401 응답 | ✅ 401 + `{"message":"Unauthorized"}` |
### 부가 수정 사항
- `proxy.ts` matcher 수정: `api/auth``api/` (API 라우트 전체를 미들웨어 제외 대상으로 변경)
- 기존: API 라우트에 미들웨어가 개입하여 307 리다이렉트 반환
- 수정 후: API 라우트는 `withApiHandler`가 직접 401 처리
+321
View File
@@ -0,0 +1,321 @@
# Phase 11. 이메일 발송 환경 및 자동 발송
## 목표
연장 승인 / 업그레이드 승인 완료 시 마에스트로에게 안내 이메일을 자동 발송한다.
발송은 DB 커밋 성공 후 트랜잭션 바깥에서 수행하며, 발송 실패가 승인 결과에 영향을 주지 않는다.
---
## 1. 사전 조사 결과 (기존 mouse-typing 코드 확인)
### SMTP 설정 (`mail_setting.php`, `send_jinaju_mail.php`)
| 항목 | 값 |
|---|---|
| SMTP host | `smtp.gmail.com` |
| Port | `465` |
| Secure | SMTPS (implicit TLS) |
| Auth username | `support+chocomae@jinaju.com` |
| From name | `초코마에` |
| From address | `support+chocomae@jinaju.com` |
| Reply-to | `support+chocomae@jinaju.com` |
| BCC | `support+chocomae.automail@jinaju.com` |
Gmail App Password는 환경변수 `MAIL_SMTP_PASSWORD`로 분리. 코드에 하드코딩하지 않는다.
### 연장 완료 이메일 (`extensionDone`, `mail_contents.php:165`)
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정 유효기간 연장 완료`
- **본문 변수**: `{maestro_name}`, `{new_available_date}` (YYYY-MM-DD 형식)
- **본문 내용**:
- `{maestro_name} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.`
- `새로운 유효기간 : {new_available_date}`
- 상위 요금제 업그레이드 안내, 6개월 후 자동 삭제 안내
### 업그레이드 완료 이메일 (2가지 케이스, `mail_contents.php:209,255`)
업그레이드 전 상태(`RegisteredActivateStatus`)에 따라 본문이 다르다.
**케이스 A — 체험 → 유료** (`trialMaestroUpgradeDone`, `RegisteredActivateStatus = 1`)
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
- **본문 핵심**: `[ {prev_student_count}명 (0원 : 무료 체험 기간) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
- `{prev_price}` 없음 — 체험 기간이므로 0원 고정 문자열 사용
**케이스 B — 유료 → 유료** (`upgradeDone`, `RegisteredActivateStatus = 2`)
- **제목**: 케이스 A와 동일
- **본문 핵심**: `[ {prev_student_count}명 ({prev_price}원) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
- `{prev_price}` 있음
두 케이스 모두 공통 마무리: `마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.`
---
## 2. 메일 발송 정책
| 항목 | 정책 |
|---|---|
| **발송 시점** | DB 커밋 완료 후, 트랜잭션 바깥에서 발송 |
| **발송 실패 처리** | `logger.error`로 기록, 승인 응답은 200으로 그대로 반환 (롤백 없음) |
| **중복 발송 방지** | 승인 API는 이미 처리된 건(`Status ≠ 1`)에 409를 반환하므로 발송 자체가 중복 호출되지 않음 |
| **발송 비활성화** | `MAIL_SEND_ENABLED=false`이면 발송을 건너뛰고 info 로그만 남김 |
| **민감정보 마스킹** | SMTP 비밀번호는 환경변수로 관리, 로그에는 수신자 주소와 제목만 출력 |
---
## 3. 구현 순서
### 3-1. 패키지 설치
```bash
pnpm add nodemailer
pnpm add -D @types/nodemailer
```
nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다.
### 3-2. 환경변수 추가 (`.env.local`)
```dotenv
MAIL_SMTP_HOST=smtp.gmail.com
MAIL_SMTP_PORT=465
MAIL_SMTP_SECURE=true
MAIL_SMTP_USER=support+chocomae@jinaju.com
MAIL_SMTP_PASSWORD=<gmail_app_password>
MAIL_FROM_NAME=초코마에
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
MAIL_SEND_ENABLED=false # local/stage 발송 테스트 전까지 false 유지
```
### 3-3. `lib/mail.ts` 구현 (신규)
```
lib/mail.ts
```
**내부 구조**
```
createTransporter() — nodemailer transport 생성 (환경변수 기반)
sendMail(options) — 공통 발송 함수 (MAIL_SEND_ENABLED 체크, 로그, BCC)
sendExtensionDoneEmail(...) — 연장 완료 이메일
sendUpgradeDoneEmail(...) — 업그레이드 완료 이메일 (체험/유료 케이스 내부 분기)
```
**`sendExtensionDoneEmail` 시그니처**
```typescript
sendExtensionDoneEmail(
maestroName: string,
maestroEmail: string,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
```
**`sendUpgradeDoneEmail` 시그니처**
```typescript
sendUpgradeDoneEmail(
maestroName: string,
maestroEmail: string,
registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2)
registeredAccountType: number,
requestedAccountType: number,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
```
내부에서 `registeredActivateStatus === ACTIVATE_STATUSES.TRIAL`이면 체험 케이스 본문을 사용한다.
계정별 학생 수와 가격은 `getAccountTypePlayerCount()`, `getAccountTypePrice()` (`lib/utils.ts`)를 호출한다.
### 3-4. `lib/extension-requests.ts` 수정
`approveExtensionRequest`의 반환 타입에 이메일 발송에 필요한 정보를 추가한다.
`getActionTarget`이 이미 `include: { maestro: true }`를 사용하므로 추가 쿼리 없이 `request.maestro.Email`을 사용할 수 있다.
**변경 전**
```typescript
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }>
```
**변경 후**
```typescript
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}>
```
트랜잭션 내부 마지막에 반환값에 `maestroName`, `maestroEmail` 추가:
```typescript
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
};
```
### 3-5. `lib/upgrade-requests.ts` 수정
현재 `getActionTarget``maestro_upgrade`만 조회하고 `maestro`를 포함하지 않는다. include를 추가해야 한다.
**`getActionTarget` 변경**
```typescript
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } }, // 추가
});
```
**`approveUpgradeRequest` 반환 타입 변경**
```typescript
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}>
```
트랜잭션 반환값에 추가:
```typescript
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
};
```
### 3-6. API route 수정
**`app/api/extension-requests/[id]/route.ts`**
```typescript
if (body.data.action === "approve") {
const result = await approveExtensionRequest(maestroExtensionID);
logger.info(CTX, "approved", { id: maestroExtensionID, duration: Date.now() - t0 });
// 메일 발송 (실패해도 응답에 영향 없음)
void sendExtensionDoneEmail(
result.maestroName,
result.maestroEmail,
formatDate(result.availableActivateDateTime),
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
}
```
**`app/api/upgrade-requests/[id]/route.ts`**
```typescript
if (body.data.action === "approve") {
const result = await approveUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "approved", { id: maestroUpgradeID, duration: Date.now() - t0 });
void sendUpgradeDoneEmail(
result.maestroName,
result.maestroEmail,
result.registeredActivateStatus,
result.registeredAccountType,
result.requestedAccountType,
formatDate(result.availableActivateDateTime),
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
}
```
`void`를 붙여 메일 발송을 백그라운드로 실행하되, `sendMail` 내부에서 `await`로 실제 발송을 기다린다. 메일 발송 실패는 `logger.error`로만 처리한다.
---
## 4. 이메일 본문 (참조용)
### 연장 완료
**제목**: `[초코마에] {maestroName} - 마에스트로 계정 유효기간 연장 완료`
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
* 새로운 유효기간 : {newAvailableDate}<br/>
<br/>
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
<br/>
감사합니다.
```
### 업그레이드 완료 — 체험 → 유료
**제목**: `[초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
```
### 업그레이드 완료 — 유료 → 유료
**제목**: 위와 동일
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
```
---
## 5. 검증 절차
| 항목 | 방법 | 기대 결과 |
|---|---|---|
| `MAIL_SEND_ENABLED=false` 상태에서 승인 | 연장/업그레이드 승인 API 호출 | 승인 성공, 메일 발송 없음, `info: send skipped` 로그 출력 |
| `MAIL_SEND_ENABLED=true` — 연장 승인 이메일 | 연장 승인 후 수신 메일함 확인 | 제목/본문/수신자/BCC 형식 일치 |
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (체험) | 체험 마에스트로 업그레이드 승인 | `0원 : 무료 체험 기간` 문구 포함, 제목/본문 일치 |
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (유료) | 유료 마에스트로 업그레이드 승인 | `{prev_price}원` 포함, 제목/본문 일치 |
| SMTP 오류 시 응답 | SMTP 연결 실패 환경에서 승인 | 승인 API 200 응답, `error: send failed` 로그 |
| 로그 민감정보 확인 | 발송 로그 출력 | 수신자 주소, 제목만 출력. SMTP 비밀번호 미포함 |
+222
View File
@@ -0,0 +1,222 @@
# Phase 16: 반응형 웹 디자인 (Responsive Web Design)
## 목표
모바일 화면(360~430px 기준)에서도 관리자 패널을 정상적으로 사용할 수 있도록 세 가지 영역을 개선한다.
1. 사이드바 — 모바일 햄버거 메뉴 + 슬라이드인 패널
2. 표 — 섹션 경계 내에서 가로 스크롤되도록 헤더 레이아웃 수정
3. 페이지네이션 — 모바일에서도 오른쪽 정렬 유지
---
## 1. 모바일 사이드바
### 현재 상태
`app/(admin)/layout.tsx``<aside>``hidden md:block`으로 모바일에서 완전히 숨겨진다. 헤더에 햄버거 버튼이 없어 모바일에서 내비게이션 자체에 접근할 수 없다.
`layout.tsx`는 서버 컴포넌트(`await auth()` 사용)이므로 `useState`를 직접 쓸 수 없다.
### 구현 방식
**추천 닫기 UI: 배경 오버레이(backdrop) + 사이드바 내 X 버튼 조합**
- 오버레이: 사이드바 외부 어디든 탭하면 닫힘 → 모바일 터치 UX에 가장 자연스러움
- X 버튼: 사이드바 상단에 명시적으로 표시 → 처음 쓰는 사용자도 직관적으로 이해
- ESC 키: `useEffect``keydown` 리스너 추가 → 접근성 향상
두 가지 닫기 수단을 모두 제공해 사용자가 선호하는 방식으로 닫을 수 있다.
### 생성할 파일
#### `app/(admin)/MobileNav.tsx` (신규, `"use client"`)
```tsx
"use client";
// useState(isOpen), useEffect(ESC 키)
// 렌더링:
// 1. <button> 햄버거 (md:hidden — 데스크탑에서 숨김)
// 2. isOpen일 때: fixed inset-0 z-20 bg-black/50 backdrop (클릭 시 닫힘)
// 3. 슬라이드인 패널: fixed inset-y-0 left-0 z-30 w-64
// - 상단: "chocoadmin" 로고 + X 버튼 (오른쪽)
// - 내비게이션 링크 목록 (navItems 배열)
// - 링크 클릭 시 자동 닫힘 (useRouter 또는 onClick)
// 4. 애니메이션: translate-x-0 ↔ -translate-x-full, transition-transform duration-200
```
패널이 열릴 때 `document.body``overflow: hidden`을 걸어 배경 스크롤을 막는다.
### 수정할 파일
#### `app/(admin)/layout.tsx`
```diff
+ import { MobileNav } from "./MobileNav";
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
+ <MobileNav navItems={navItems} />
<div>
<p className="text-sm font-medium">{session.user.name}</p>
...
</div>
<form action={logoutAction}>...</form>
</header>
```
- 데스크탑 `<aside>`는 그대로 유지 (`hidden md:block`)
- `MobileNav`가 렌더링하는 햄버거 버튼은 `md:hidden`이므로 데스크탑에서는 보이지 않음
- 헤더 왼쪽: 햄버거(모바일) | 사용자 정보(기존) → `justify-between`으로 양 끝 배치 유지
---
## 2. 표 — 섹션 경계 내 가로 스크롤
### 원인 분석
#### 왜 StudentsSection / LogsSection은 정상 동작하는가
두 섹션 모두 `"use client"` 컴포넌트로, `useEffect` 안에서 데이터를 직접 fetch한다.
- SSR 시점: `data === null` → 로딩 중... `<div class="h-24 ...">` 렌더
- 섹션 너비가 뷰포트 기준으로 확정된 뒤, 클라이언트에서 데이터를 받아 표를 삽입
- 표가 나타날 때 이미 섹션 너비가 확정되어 있으므로 `overflow-x-auto`가 스크롤을 정상 처리
#### 왜 ExtensionRequestsSection / UpgradeRequestsSection은 섹션이 늘어나는가
두 섹션도 `"use client"`이지만, **데이터를 서버에서 props로 받아** SSR 초기 렌더에 표가 포함된다.
- SSR 시점: `extensionRequests` / `upgradeRequests` props가 이미 채워진 상태로 렌더
- 초기 레이아웃 계산 시점에 `min-w-[560px]` 표가 DOM에 존재
- `section` 요소에 `overflow` 설정이 없어(`overflow: visible` 기본값) 표의 너비가 섹션을 밀어냄
- 섹션 자체가 560px+ 로 확장 → `div.overflow-x-auto`도 그 너비를 그대로 가져가므로 스크롤이 생기지 않음
#### 핵심 수정: `section`에 `overflow-hidden` 추가
`section` 요소에 `overflow: hidden`을 적용하면 BFC(Block Formatting Context)가 확립되어 어떤 자식도 섹션 너비를 밀어낼 수 없다. 섹션은 항상 뷰포트 기준 너비로 고정되고, 표는 `div.overflow-x-auto` 안에서 가로 스크롤된다.
```diff
- <section className="rounded-lg border bg-background p-5">
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
```
`overflow: hidden`은 섹션의 **가로 팽창만** 막는다. 섹션의 `height``auto`이므로 세로로는 내용 크기에 맞게 늘어나며 내용이 잘리지 않는다. 또한 `div.overflow-x-auto`의 스크롤바는 섹션 내부에 위치하므로 정상 표시된다.
#### 추가 수정: 섹션 헤더 레이아웃 (UX 목적)
`overflow-hidden`으로 표 팽창은 막을 수 있지만, 헤더의 버튼/셀렉트 영역이 모바일 뷰포트보다 넓을 경우 `overflow: hidden`에 의해 잘려 보이지 않는다. 버튼 접근성을 위해 헤더도 모바일에서 세로 스택으로 변경한다.
### 수정할 파일
#### `app/(admin)/maestros/[maestroId]/ExtensionRequestsSection.tsx`
```diff
- <section className="rounded-lg border bg-background p-5">
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
- <div className="flex items-start justify-between gap-2">
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 ...>연장 신청 이력</h2>
<p ...>전체 N건 중 최근 20건</p>
</div>
- <div className="flex flex-col items-end gap-1">
+ <div className="flex flex-col items-start gap-1 sm:items-end">
<Button ...>연장</Button>
...
</div>
</div>
```
#### `app/(admin)/maestros/[maestroId]/UpgradeRequestsSection.tsx`
```diff
- <section className="rounded-lg border bg-background p-5">
+ <section className="overflow-hidden rounded-lg border bg-background p-5">
- <div className="flex items-start justify-between gap-2">
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 ...>업그레이드 신청 이력</h2>
<p ...>전체 N건 중 최근 20건</p>
</div>
- <div className="flex flex-col items-end gap-1">
+ <div className="flex flex-col items-start gap-1 sm:items-end">
- <div className="flex items-center gap-2">
+ <div className="flex flex-wrap items-center gap-2">
<select ...>...</select>
<Button ...>업그레이드</Button>
</div>
...
</div>
</div>
```
#### StudentsSection / LogsSection
수정 불필요. 클라이언트 fetch 방식으로 인해 SSR 초기 렌더에 표가 없어 섹션 너비가 먼저 확정되므로 현재 구조 그대로 정상 동작한다.
---
## 3. 페이지네이션 오른쪽 정렬
### 현재 상태
모든 페이지네이션 컨테이너:
```
flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between
```
모바일(flex-col)에서 nav(페이지 버튼)는 페이지 크기 선택 아래에 위치하며 왼쪽 정렬된다.
### 수정 방법
`<nav>` 요소에 `self-end` 추가, 데스크탑에서는 `sm:self-auto`로 복원:
```diff
<nav
aria-label="..."
- className="flex flex-wrap items-center gap-1.5"
+ className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
>
```
- 모바일(flex-col): `align-self: flex-end` → nav가 오른쪽으로 붙음
- 데스크탑(flex-row + justify-between): `sm:self-auto` → 기존 `items-center` 적용, 수직 중앙 정렬 유지
페이지 크기 선택 요소("페이지당 N개 표시")는 변경하지 않아 왼쪽에 그대로 유지.
### 수정 대상 파일 및 위치
| 파일 | nav aria-label |
|---|---|
| `app/(admin)/extension-requests/page.tsx` | "연장 신청 목록 페이지" |
| `app/(admin)/upgrade-requests/page.tsx` | "업그레이드 신청 목록 페이지" |
| `app/(admin)/maestros/page.tsx` | "마에스트로 목록 페이지" |
| `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` | "학생 목록 페이지" |
| `app/(admin)/maestros/[maestroId]/LogsSection.tsx` | "처리 로그 페이지" |
---
## 작업 순서
1. `MobileNav.tsx` 생성 → `layout.tsx` 수정 (사이드바)
2. `ExtensionRequestsSection.tsx`: `section``overflow-hidden` 추가 + 헤더 flex-col 수정
3. `UpgradeRequestsSection.tsx`: `section``overflow-hidden` 추가 + 헤더 flex-col 수정
4. 페이지네이션 nav 5개 파일 수정
## 검증 포인트
- [ ] 모바일(375px)에서 햄버거 버튼 표시 확인
- [ ] 사이드바 슬라이드인/아웃 애니메이션 확인
- [ ] 오버레이 탭 → 사이드바 닫힘 확인
- [ ] X 버튼 탭 → 사이드바 닫힘 확인
- [ ] 데스크탑(≥768px)에서 햄버거 버튼 미표시 확인
- [ ] `ExtensionRequestsSection` 섹션이 모바일 뷰포트 너비 안에 머무르는지 확인
- [ ] `ExtensionRequestsSection` 표가 섹션 내부에서 가로 스크롤되는지 확인
- [ ] `ExtensionRequestsSection` 헤더 버튼이 모바일에서 잘리지 않고 표시되는지 확인
- [ ] `UpgradeRequestsSection` 섹션이 모바일 뷰포트 너비 안에 머무르는지 확인
- [ ] `UpgradeRequestsSection` 표가 섹션 내부에서 가로 스크롤되는지 확인
- [ ] `UpgradeRequestsSection` 요금제 셀렉트 + 버튼이 모바일에서 접근 가능한지 확인
- [ ] 모든 페이지네이션 nav가 모바일에서 오른쪽 정렬 확인
- [ ] 데스크탑에서 기존 레이아웃 회귀 없음 확인
+139
View File
@@ -0,0 +1,139 @@
# Phase 8. 검증 및 안정화
## 1. 목표
`docs/plan/project-plan.md``Phase 8. 검증 및 안정화`를 수행하기 위한 검증 항목과 실행 절차를 정리한다.
Phase 8의 목표는 다음 항목을 확인하는 것이다.
- 승인/취소/거절 API가 트랜잭션으로 처리되는지 확인
- 이미 처리된 신청을 재처리할 때 `409` 오류로 막히는지 확인
- 비인증 접근이 차단되는지 확인
- Synology NAS Docker 재시작 후 정상 동작하는지 확인
## 2. 코드 검증 결과
### 2-1. 연장 신청 처리
대상 파일:
- `lib/extension-requests.ts`
- `app/api/extension-requests/[id]/route.ts`
확인 내용:
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
- 취소 처리는 `db.$transaction()` 안에서 실행된다.
- 처리 대상 조회 시 `maestro_extension.Status``REQUEST_STATUSES.REQUESTED`인지 확인한다.
- 이미 처리된 건은 `ExtensionRequestActionError`를 던지고 API route에서 `409`로 응답한다.
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
### 2-2. 업그레이드 신청 처리
대상 파일:
- `lib/upgrade-requests.ts`
- `app/api/upgrade-requests/[id]/route.ts`
확인 내용:
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
- 거절 처리는 `db.$transaction()` 안에서 실행된다.
- 처리 대상 조회 시 `maestro_upgrade.Status``REQUEST_STATUSES.REQUESTED`인지 확인한다.
- 이미 처리된 건은 `UpgradeRequestActionError`를 던지고 API route에서 `409`로 응답한다.
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
### 2-3. 인증 프록시
대상 파일:
- `proxy.ts`
확인 내용:
- `/login`만 공개 경로로 둔다.
- 비로그인 사용자가 관리자 경로에 접근하면 `/login?callbackUrl=...`로 이동한다.
- HTTPS 배포 환경에서 Auth.js secure session cookie를 읽도록 `secureCookie`를 설정한다.
## 3. 실행 검증 절차
### 3-1. 로컬 기본 검증
```bash
pnpm lint
pnpm build
pnpm db:check
```
### 3-2. 비인증 접근 차단 확인
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
```bash
curl -I https://chocoadmin-stage.jisangs.com/maestros
```
기대 결과:
- `/login?callbackUrl=%2Fmaestros`로 리다이렉트된다.
### 3-3. 로그인 후 화면 확인
브라우저에서 확인한다.
- `https://chocoadmin-stage.jisangs.com/login`
- `/maestros`
- `/extension-requests`
- `/upgrade-requests`
로그인 후 `ERR_TOO_MANY_REDIRECTS`가 발생하면 다음을 확인한다.
- `.env.stage``AUTH_URL`
- `.env.stage``NEXTAUTH_URL`
- Nginx Proxy Manager의 forward target
- 브라우저 쿠키 삭제 여부
### 3-4. 중복 처리 방지 확인
스테이지 DB에서 테스트 가능한 요청 건을 대상으로 진행한다.
1. `/extension-requests` 또는 `/upgrade-requests`에서 `요청` 상태 건을 선택한다.
2. 승인/취소/거절 중 하나를 실행한다.
3. 같은 요청에 같은 액션을 다시 실행한다.
4. 두 번째 요청이 실패하는지 확인한다.
기대 결과:
- 첫 번째 요청은 성공한다.
- 두 번째 요청은 `409` 상태와 `이미 처리된 ... 신청입니다.` 메시지로 실패한다.
- 목록을 새로고침하면 상태가 변경되어 있다.
- 관련 `maestro_log`가 1회만 생성된다.
주의:
- 이 검증은 DB 데이터를 실제로 변경한다.
- 운영 DB에서는 read-only 리허설 또는 별도 테스트 데이터를 사용한 뒤 진행한다.
### 3-5. Docker 재시작 확인
NAS에서 실행한다.
```bash
cd /volume1/docker/service/jisangs/chocoadmin-stage
docker compose -f docker-compose.stage.yml restart
docker compose -f docker-compose.stage.yml ps
docker compose -f docker-compose.stage.yml logs --tail=100
```
기대 결과:
- `chocoadmin-stage` 컨테이너가 `Up` 상태다.
- 로그에 Next.js ready 메시지가 출력된다.
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
## 4. 안정화 메모
- 스테이지는 `proxy-network`를 통해 Nginx Proxy Manager와 연결한다.
- 스테이지 컨테이너는 host port `3000`을 직접 bind하지 않고 container port `3000`만 노출한다.
- `AUTH_URL``NEXTAUTH_URL`은 실제 외부 접속 URL과 scheme까지 정확히 일치해야 한다.
- HTTPS 배포 환경의 로그인 루프는 secure cookie 이름 불일치가 원인일 수 있다.
+144
View File
@@ -0,0 +1,144 @@
# Phase 9. 실행 로그 및 DB 쿼리 로그
## 로그 정책
### 환경 구분
| `APP_ENV` | 설명 |
|---|---|
| `local` | 맥미니 개발 환경 |
| `stage` | NAS에서 stage DB로 구동하는 검증 환경 |
| `production` | NAS에서 운영 DB로 구동하는 실제 서비스 환경 |
`NODE_ENV`는 Next.js 빌드 방식(dev/prod)을 제어하고, `APP_ENV`는 로그 레벨·상세도를 제어한다.
### 로그 레벨
| `LOG_LEVEL` | 출력 대상 |
|---|---|
| `debug` | debug + info + warn + error |
| `info` | info + warn + error |
| `warn` | warn + error |
| `error` | error만 |
`LOG_LEVEL`을 명시하지 않으면 `APP_ENV=production`일 때 `info`, 그 외는 `debug`가 기본값.
### 출력 형식
```
[2026-06-22T10:00:00.000Z] [INFO ] [maestros] list fetched {"totalCount":120,"duration":45}
[2026-06-22T10:00:00.000Z] [WARN ] [extension-requests/[id]] 이미 처리된 연장 신청입니다. {"id":5,"status":409,"duration":12}
[2026-06-22T10:00:00.000Z] [ERROR] [upgrade-requests/[id]] unexpected error {"id":3,"error":"...","duration":5}
```
- `warn` / `error`는 stderr, 그 외는 stdout으로 출력한다.
- JSON 데이터는 한 줄로 직렬화한다 (Docker 로그 파싱 편의).
### 민감정보 마스킹 대상
아래 값은 로그에 포함하지 않는다:
- 관리자 패스워드 (form 입력값, DB 조회 파라미터)
- `AUTH_SECRET` / `NEXTAUTH_SECRET`
- `DATABASE_URL` (패스워드 포함)
- 세션 토큰 / JWT 페이로드 원문
- SMTP 패스워드
현재 `auth.ts`의 raw SQL 파라미터에 패스워드가 포함될 수 있으므로 DB query 이벤트 핸들러 구현 시 `PASSWORD(` 포함 쿼리의 params를 마스킹한다 (Phase 9 DB query 로그 구현 시 처리).
---
## 환경변수
| 변수 | 설명 | 기본값 |
|---|---|---|
| `APP_ENV` | 환경 구분 (`local`\|`stage`\|`production`) | `local` |
| `LOG_LEVEL` | 최소 로그 레벨 (`debug`\|`info`\|`warn`\|`error`) | `APP_ENV`에 따라 자동 |
| `DB_SLOW_QUERY_MS` | DB 쿼리 slow query 임계값(ms) — DB query 로그 구현 시 사용 | `1000` |
### 권장 `.env` 설정
**.env.local (개발)**
```env
APP_ENV=local
LOG_LEVEL=debug
```
**.env.stage (검증)**
```env
APP_ENV=stage
LOG_LEVEL=debug
```
**.env.production (운영)**
```env
APP_ENV=production
LOG_LEVEL=info
DB_SLOW_QUERY_MS=500
```
---
## 적용 현황
### 완료
- `lib/logger.ts` — 공통 logger 모듈 (레벨 필터링, 포맷, stderr/stdout 분기)
- 로그인 성공/실패 로그 (`auth.ts`)
- 마에스트로 목록/상세 조회 로그
- 연장 신청 조회/승인/취소 로그 (409는 warn, 그 외 예외는 error)
- 업그레이드 신청 조회/승인/거절 로그 (409는 warn, 그 외 예외는 error)
### 미완료 (별도 확인 후 진행)
- **Correlation ID**: Edge Middleware(`proxy.ts`)에서 `x-correlation-id` 헤더 생성 → API route에서 AsyncLocalStorage로 전달하는 구조. 구현 전 설계 확정 필요.
- **DB query 로그**: `@prisma/adapter-mariadb` + Prisma query 이벤트 호환성 확인 필요. `$on('query', handler)` 발화 여부를 local에서 테스트한 뒤 진행.
- **결과 row/count 로그**: Prisma query 이벤트에 결과 데이터가 없음. `$extends` query 컴포넌트 방식으로 재설계하거나 계획 범위 축소 필요.
---
## Docker / NAS 로그 운영 절차
### 애플리케이션 로그 확인
```bash
# 실시간 스트리밍
docker compose logs -f chocoadmin
# 최근 100줄
docker compose logs --tail=100 chocoadmin
# 특정 시간 이후
docker compose logs --since="2026-06-22T10:00:00" chocoadmin
```
### warn/error만 필터링
```bash
docker compose logs -f chocoadmin 2>&1 | grep -E '\[(WARN |ERROR)\]'
```
### Nginx Proxy Manager 로그와 함께 확인
Synology NAS에서 Nginx Proxy Manager(NPM)와 chocoadmin 로그를 같이 보려면:
```bash
# NPM access log (컨테이너 이름은 환경에 맞게 조정)
docker logs nginx-proxy-manager 2>&1 | tail -50
# 두 로그를 동시에
docker compose logs -f chocoadmin &
docker logs -f nginx-proxy-manager
```
NPM의 access log에는 클라이언트 IP, HTTP method, path, status code, 응답 시간이 기록되고, chocoadmin 로그에는 인증 결과와 비즈니스 처리 결과가 기록된다. `status=409`를 NPM 로그에서 발견했을 때 chocoadmin의 `[WARN]` 로그와 함께 원인을 추적한다.
### stage/production 로그 레벨 차이
| 항목 | stage | production |
|---|---|---|
| `LOG_LEVEL` | `debug` | `info` |
| 요청 시작 로그 | 출력 (debug) | 미출력 |
| 성공 응답 로그 | 출력 (info) | 출력 (info) |
| 인증 실패/409 로그 | 출력 (warn, stderr) | 출력 (warn, stderr) |
| 예외/500 로그 | 출력 (error, stderr) | 출력 (error, stderr) |
+59
View File
@@ -0,0 +1,59 @@
# Chocomae Email Sending Report
조사 기준:
- 현재 `chocoadmin` 저장소에는 이메일 발송 구현이 없다.
- 기존 서비스 원본은 `/Users/jisangs/work/jinaju/git/web/mouse-typing` 기준으로 확인했다.
- 핵심 구현 파일:
- `src/web/server/mail/send_jinaju_mail.php`
- `src/web/server/mail/mail_contents.php`
- `src/web/server/mail/mail_setting.php`
## 공통 발송 설정
- 발송자 이름: `초코마에`
- 발송자 주소: `support+chocomae@jinaju.com`
- BCC: `support+chocomae.automail@jinaju.com`
- 기존 구현은 PHPMailer + SMTP를 사용한다.
- 일부 배치 스크립트는 NCloud Mail API 템플릿 ID를 사용한다.
- 기존 코드에는 SMTP/API 키가 하드코딩되어 있으므로, 신규 구현 시 반드시 환경변수 또는 배포 시크릿으로 분리해야 한다.
## 운영 플로우에서 호출되는 메일
| # | 메일 | 호출 위치 | 제목 | 주요 내용 |
|---|---|---|---|---|
| 1 | 마에스트로 체험 등록 완료 | `src/web/server/maestro/add_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 신청 아이디, 요금제, 2주 무료 체험 종료일, 계정 정보 변경 안내 |
| 2 | 관리자 등록 승인 완료 | `src/web/server/admin/register_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 계정 활성화 완료, 1년 사용, 업그레이드/만료/정보 변경 안내 |
| 3 | 연장 신청 입금 안내 | `src/web/server/maestro/request_extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장을 위한 입금 안내` | 현재 요금제, 기존 유효기간, 새 유효기간, 입금 계좌, 금액, 송금자명 안내 |
| 4 | 연장 완료 | `src/web/server/admin/extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장 완료` | 유효기간 +1년 연장 완료, 새 유효기간 안내 |
| 5 | 체험 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 무료 체험 요금제, 신청 업그레이드 요금제, 전체 결제 금액, 입금 계좌 안내 |
| 6 | 일반 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 요금제, 업그레이드 요금제, 차액 결제 금액, 입금 계좌 안내 |
| 7 | 체험 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 무료 체험 요금제에서 유료 요금제로 업그레이드 완료, 유효기간 오늘부터 1년 |
| 8 | 일반 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년 |
| 9 | 마에스트로 아이디 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] {{update_maestro_name}} - 마에스트로 계정 아이디 정보 변경 알림` | 변경 전/후 아이디, 다음 로그인 시 변경된 아이디 사용 안내 |
| 10 | 마에스트로 이메일 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] 마에스트로 계정 이메일 정보 변경 알림` | 변경 전/후 이메일, 이후 알림은 변경 후 이메일로 발송 안내 |
## 배치/만료 관련 메일
| # | 메일 | 호출 위치 | 제목 또는 템플릿 | 주요 내용 |
|---|---|---|---|---|
| 11 | 유효기간 7일 전 만료 안내 | `src/php-cli/batch/send_mail_to_7days_left_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 - 7일 후 만료 안내` | 유효기간, 7일 후 휴면 전환, 연장/업그레이드 방법 안내 |
| 12 | 유효기간 종료 안내 | `src/php-cli/batch/send_mail_to_overdated_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 종료 안내` | 휴면 전환, 학생 로그인 불가, 6개월 후 삭제 가능성, 재활성화 방법 안내 |
| 13 | 미입금 계정 삭제 예고 | `src/php-cli/batch/old/send_mail_to_unpaid_maestro.php`, Python wrapper `send_mail_to_unpaid_maestro.py` | NCloud templateSid `337`; PHPMailer 템플릿 제목은 `[초코마에] {{maestro_name}} - 미입금 계정 삭제 예고 안내` | 신청 후 일주일간 미입금, 오늘 중 입금 없으면 계정 등록 정보 삭제 예정 |
| 14 | 유효기간 30일 전 안내 | `src/php-cli/batch/old/send_mail_to_30days_left_maestro.php`, Python wrapper `send_mail_to_expire_maestro.py` | NCloud templateSid `333` | 코드에는 파라미터만 확인됨: `maestro_name`, `available_date`. 실제 제목/본문은 NCloud 콘솔 템플릿에 저장된 것으로 보인다. |
## 템플릿/테스트 흔적만 확인된 메일
| 메일 | 위치 | 상태 |
|---|---|---|
| 등록 입금 안내 | `sendMailRegisterBankInfo`, `mail_contents.php``registrationBankInfo` | 제목/본문은 있으나 주요 등록 플로우에서는 호출되지 않고 관리자 테스트 화면에서 호출된다. |
| 클로즈베타 등록 입금 안내 | `mail_contents.php``closedBetaRegistrationBankInfo` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
| 클로즈베타 등록 완료 | `mail_contents.php``closedBetaRegistrationDone` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
| 클로즈베타 유료 결제 감사/기간 +1개월 | `sendMailToClosedBetaPaidMestroEmail`, `src/php-cli/batch/old/send_mail_to_closed_beta_paid_maestro.php` | old 배치에서 호출된다. |
## 구현 시 참고
- 연장/업그레이드 승인 메일은 기존 PHP 코드에서 DB 변경 후 발송한다.
- 신규 구현에서는 DB 트랜잭션 커밋 이후 메일 발송을 분리하는 것이 안전하다.
- 메일 발송 실패가 신청 승인/상태 변경을 롤백해야 하는지는 별도 정책 결정이 필요하다.
- 계좌 정보, 발송자/BCC, SMTP/API 인증 정보는 코드에 하드코딩하지 말고 환경변수로 관리해야 한다.
+200
View File
@@ -0,0 +1,200 @@
# chocoadmin 전체 검토 및 개선 계획
작성일: 2026-07-04
검토 범위: Phase 0–12 전체 구현 (인증, 마에스트로 목록/상세/수정, 연장/업그레이드 신청·승인, 이메일 발송, 로깅)
---
## 1. 요약
코드·로직·비즈니스 측면을 전수 검토한 결과, 데이터 파괴적인 치명 결함은 없으나
**(1) 로그를 통한 마에스트로 비밀번호 노출 가능성, (2) 동시 승인 이중 처리 가능성,
(3) 타임존 미확정으로 인한 만료일 오차** 세 가지가 우선 수정 대상이다.
그 외 체험(TRIAL) 계정 관련 비즈니스 규칙 공백과 트랜잭션/로그 기록 비일관성이 있다.
| 등급 | 건수 | 내용 |
|---|---|---|
| P0 (즉시 수정) | 3 | 비밀번호 로그 노출, 동시 승인 레이스, 타임존 정책 |
| P1 (다음 작업) | 7 | TRIAL 규칙 공백, 승인 시점 재검증, 트랜잭션/로그 일관성, 이메일 실패 처리 |
| P2 (보완) | 8 | 매직 넘버, 에러 응답 일관성, 세션 정책, 목록 기본 필터 등 |
---
## 2. P0 — 즉시 수정
### P0-1. 연장 승인 시 maestro 전체 컬럼 조회 → Password 로그 노출 위험
- 위치: `lib/extension-requests.ts:235` (`getActionTarget``include: { maestro: true }`)
- 문제: `maestro.Password`(char 50)까지 조회된다. `lib/db.ts`의 debug 레벨 결과 테이블 로깅이
조회 행 전체를 콘솔에 출력하므로, local/stage(debug 기본)에서 승인 처리 시
**마에스트로 비밀번호가 로그에 그대로 남는다.** phase9 마스킹 정책 위반.
- 대조: `lib/upgrade-requests.ts:253`은 이미 `select: { Name, Email }`로 제한하고 있어 비일관.
- 개선:
1. extension 쪽 `getActionTarget`을 upgrade와 동일하게 `select` 방식으로 변경
(필요 컬럼: 요청 전체 + `maestro.Name`, `Email`, `AvailableActivateDateTime`).
2. 방어선 추가: `lib/db.ts` 결과 로깅에서 `Password` 컬럼명을 만나면 `***` 마스킹.
### P0-2. 동시 승인 레이스 컨디션 (이중 승인 → +2년 연장)
- 위치: `lib/extension-requests.ts:116-157`, `lib/upgrade-requests.ts:123-168`
- 문제: 트랜잭션 안에서 `findUnique``Status = 1` 확인 후 `update`하는 read-then-write 패턴.
MariaDB 기본 격리수준(REPEATABLE READ)에서는 두 관리자가 동시에 승인 버튼을 누르면
둘 다 `Status = 1`을 읽고 둘 다 진행 → 연장이 두 번 적용(만료일 +2년), 로그·메일 2회 발생 가능.
- 개선(택1):
- A안(권장): 상태 전이를 조건부 UPDATE로 원자화.
`updateMany({ where: { MaestroExtensionID, Status: 1 }, data: { Status: 3 } })`
`count === 0`이면 409 throw. maestro 갱신은 그 뒤에 수행.
- B안: `$queryRaw``SELECT ... FOR UPDATE`로 요청 행 잠금 후 진행.
- 적용 대상: 연장 승인/취소, 업그레이드 승인/거절 4개 함수 모두.
### P0-3. 타임존 정책 미확정 — 만료일·이메일 날짜 오차
- 위치:
- `lib/utils.ts:93-105` `calculateExtendedAvailableDate``setHours(0,0,0,0)` — 서버 로컬 자정
- `lib/utils.ts:63-75` `formatDate` — 서버 로컬 기준
- `lib/upgrade-requests.ts:360-366` `calculateUpgradeAvailableDate`
- `docker-compose.yml` / `docker-compose.stage.yml``TZ` 환경변수 없음 → 컨테이너는 UTC
- 문제: 기존 chocomae(PHP)는 DB 서버 `NOW()`(KST 추정) 기준으로 날짜를 기록해 왔다.
chocoadmin 컨테이너가 UTC로 돌면:
- 연장 승인 시 "자정"이 UTC 자정(KST 09:00)으로 저장돼 기존 데이터와 9시간 어긋남.
- `formatDate`로 만든 이메일 표기 날짜가 KST 기준과 하루 차이 날 수 있음 (KST 00:0008:59 구간).
- `updateMaestro`의 날짜 변경 로그 remark는 `toISOString().slice(0,10)`(UTC)인데
다른 곳은 로컬 기준 — 같은 값이 화면/로그/메일에서 다르게 보일 수 있음 (`lib/maestros.ts:216-220`).
- 개선:
1. docker-compose 두 파일에 `TZ: Asia/Seoul` 명시 (앱 레벨 최소 변경으로 기존 동작 재현).
2. 날짜→문자열 변환을 한 가지 방식으로 통일: remark의 `toISOString().slice(0,10)`
`formatDate()`로 교체.
3. stage에서 승인 → DB 저장값 → 이메일 표기 날짜를 실측 검증 (phase11 잔여 항목과 함께).
---
## 3. P1 — 비즈니스 규칙·정합성
### P1-1. 체험(TRIAL) 상태 계정의 연장 신청 허용 문제
- 위치: `lib/extension-requests.ts:160-195` `createExtensionRequest`
- 문제: 유료 `AccountType`(1~5)만 검사하고 `ActivateStatus`는 검사하지 않는다.
체험 계정(ActivateStatus=1)도 AccountType 1~5를 가지므로(`TRIAL_ACCOUNT_TYPE_LABELS` 존재)
체험 계정에 연장 신청→승인하면 **결제 없이 ActivateStatus=2(활성) + 1년**이 부여된다.
승인 로직(`approveExtensionRequest`)에도 동일 검증이 없어 CANCELED(100) 계정도 재활성화된다.
- 결정 필요(비즈니스): 연장 대상을 `ActivateStatus = 2(활성)`로 제한할지,
만료/취소 계정 연장을 관리자 재량으로 허용할지.
- 활성 상태에서만 승인하도록 제한: 신청 생성과 승인 양쪽에서 `ActivateStatus` 검증 추가, 위반 시 400/409.
### P1-2. 체험 → 동일 요금제 유료 전환 신청 불가
- 위치: `lib/upgrade-requests.ts:184-186`, `UpgradeRequestsSection.tsx:42-43,110`
- 문제: `requestedAccountType <= maestro.AccountType`이면 거부한다.
그러나 체험 계정(ActivateStatus=1, AccountType=3)이 같은 3번 요금제를 유료 전환하는 것은
정당한 흐름이며, 가격 계산(`calculateAdditionalPrice`)과 완료 메일도 이미 이 케이스를 지원한다.
현재는 관리자 대행 등록이 불가능하다(같은 tier가 select에서 disabled + 서버 400).
- 개선: `ActivateStatus === TRIAL`이면 `requestedAccountType >= AccountType`(동일 tier 포함) 허용,
ACTIVE면 기존대로 `>`만 허용. UI select disabled 조건도 동일하게 분기.
### P1-3. 업그레이드 승인 시점의 재검증 없음
- 위치: `lib/upgrade-requests.ts:113-168` `approveUpgradeRequest`
- 문제: 신청 생성 시엔 상향 여부를 검증하지만 승인 시엔 하지 않는다.
신청 후 관리자가 정보 수정(Phase 10)으로 `AccountType`을 올려두면,
대기 중인 신청을 승인할 때 **사실상 다운그레이드**가 적용될 수 있다.
- 개선: 승인 트랜잭션 안에서 maestro의 현재 `AccountType`을 읽어
`RequestedAccountType`이 여전히 상향(또는 TRIAL 전환)인지 확인, 아니면 409 + 안내 메시지.
### P1-4. 연장 신청 등록이 트랜잭션 미사용 (일관성 위반)
- 위치: `lib/extension-requests.ts:160-195`
- 문제: maestro 조회 → insert가 트랜잭션 밖에서 수행된다. AGENTS 규칙
("모든 변경은 트랜잭션") 위반이며, `createUpgradeRequest`(트랜잭션 사용)와 비일관.
- 개선: `db.$transaction`으로 감싸 upgrade 쪽과 동일 구조로 정리.
### P1-5. 신청 등록 시 maestro_log 미기록
- 위치: `createExtensionRequest`, `createUpgradeRequest`
- 문제: 관리자 대행 신청 등록은 DB 변경인데 로그를 남기지 않는다.
("성공한 DB 변경 후 항상 로그 기록" 규칙 위반.) 나중에 신청 출처(마에스트로 본인 vs 관리자 대행)를
구분할 수 없다.
- 개선: 신규 타입 `request_extension_maestro` / `request_upgrade_maestro`(가칭)를 정의해
트랜잭션 안에서 기록. 타입 확정 후 AGENTS.md 로그 표에 추가.
### P1-6. 이메일 발송 실패가 관리자에게 보이지 않음
- 위치: `app/api/extension-requests/[id]/route.ts:43-47`, `app/api/upgrade-requests/[id]/route.ts:43-50`
- 문제: `void sendXxxDoneEmail(...)` fire-and-forget. 실패해도 로그만 남고
승인 응답은 `ok: true` → 관리자는 발송 성공으로 인지. 재발송 수단도 없다.
- 개선(단계적):
1. `await`로 결과를 받아 응답에 `emailSent: boolean` 포함, 실패 시 목록 UI에 경고 표시.
(커밋 후 발송이므로 트랜잭션 규칙과 충돌 없음. 지연은 SMTP 1회 호출 수준.)
2. (후속) 실패 건 재발송 버튼 또는 관리자 알림.
- 참고: 현재 Docker 상주 프로세스라 void 실행이 잘리지는 않지만, 배포 방식이 바뀌면
응답 후 프로세스 중단으로 발송 누락 가능 — await 전환으로 함께 해소된다.
### P1-7. 관리자 대행 신청 등록 시 입금 안내 메일 부재 (정책 확인)
- 문제: 기존 서비스는 마에스트로가 신청하면 입금 안내 메일을 발송한다.
관리자 대행 등록(Phase 12)은 메일을 보내지 않는데, 의도된 생략인지 확인이 안 됨.
- 확인: 기존 서비스와 달리 관리자가 대행해서 등록했을 경우 메일을 보내지 않는 것이 의도가 맞다.
- 개선: 정책 결정 후 문서화(AGENTS.md). 발송하기로 하면 기존 안내 메일 템플릿 이식.
---
## 4. P2 — 코드 품질·보완
| # | 항목 | 위치 | 내용 |
|---|---|---|---|
| P2-1 | 매직 넘버 | `lib/extension-requests.ts:125` | `ActivateStatus: 2``ACTIVATE_STATUSES.ACTIVE` 상수 사용 |
| P2-2 | 날짜 입력 silent 무시 | `lib/maestros.ts:207-227` | `availableActivateDateTime`이 파싱 불가면 조용히 건너뜀 → 400 반환이 명확 |
| P2-3 | datetime-local 파싱 TZ | `lib/maestros.ts:208` | `new Date("YYYY-MM-DDTHH:mm")`은 서버 TZ로 해석. 관리자 브라우저 TZ ≠ 서버 TZ면 저장값 이동. P0-3에서 TZ 고정 후 재확인 |
| P2-4 | 이름 중복 검사 TOCTOU | `lib/maestros.ts:153-161` | 검사와 update 사이 레이스. unique 제약이 없으므로(스키마 변경 금지) 검사를 트랜잭션 안으로 이동하는 정도로 완화. 이메일 중복 검사는 아예 없음 — 기존 서비스 정책 확인 필요 |
| P2-5 | 미확인 예외 rethrow | `lib/api-handler.ts:47-51` | 예상 외 오류가 Next 기본 500(HTML)으로 반환. JSON `{ message }` 500 응답으로 통일 |
| P2-6 | 세션 만료 기본 30일 | `auth.ts` | 관리자 도구 치고 길다. `session.maxAge` 단축(예: 8~24h) 검토 |
| P2-7 | 로그인 브루트포스 방어 없음 | `auth.ts:63-96` | rate limit 없음. 공개망 노출 시 실패 횟수 제한/지연 도입 검토. `PASSWORD()` 해시 마이그레이션은 기존 계획대로 별도 과제 |
| P2-8 | 목록 기본 필터 | `lib/extension-requests.ts:23-27` 등 | `status` 파라미터가 없으면 `optional`이 catch보다 먼저 적용돼 전체 표시. 기존 관리자 기본은 미처리(Status<2)였음 — 기본값을 `REQUESTED`로 할지 결정 (`.default(REQUEST_STATUSES.REQUESTED)`) |
| P2-9 | SQL 파라미터 인라인 로깅 | `lib/db.ts:15-29` | debug 한정이지만 이름/이메일 등 PII가 로그에 남음. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 |
| P2-10 | 연장 승인 시 요청 AccountType 불일치 | `lib/extension-requests.ts:140-150` | 신청 시점 AccountType으로 로그/메일 remark 생성 — 신청 후 요금제가 바뀌면 현재값과 다름. 승인 시 maestro 현재값 기준으로 표기할지 결정 |
---
## 5. 이행 순서 제안
1. **Step 1 (P0, 코드 수정 소규모) ✅ 완료 (2026-07-04)**
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
2. **Step 2 (P1 비즈니스 결정 → 반영) ✅ 완료 (2026-07-04)**
- P1-1: 연장 신청·승인 모두 ActivateStatus=2(ACTIVE) 필수 검증 추가
- P1-2: TRIAL 계정의 동일 tier 업그레이드(체험→유료 전환) 허용
- P1-3: 업그레이드 승인 시점에 현재 AccountType 기준 방향 재검증
- P1-4: `createExtensionRequest` 트랜잭션 감싸기
- P1-5: `request_extension_maestro` / `request_upgrade_maestro` 로그 추가
- P1-7: 관리자 대행 등록 시 입금 메일 미발송 — 의도 확인, AGENTS.md에 명시
- P2-10: 신청 시점 스냅샷 유지 — 변경 없음
3. **Step 3 (P1-6 이메일 + 검증) ✅ 완료 (2026-07-04)**
- API route 2곳: `void sendXxxDoneEmail``await emailSent`, 응답에 `emailSent: boolean` 포함
- 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨)
- stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김
4. **Step 4 (P2 일괄) ✅ 완료 (2026-07-04)**
- P2-2: `updateMaestro`에서 파싱 불가 날짜 silent skip → 400 반환으로 교체 (`lib/maestros.ts`)
- P2-5: `withApiHandler` catch 블록 `throw error` → JSON 500 응답으로 교체 (`lib/api-handler.ts`)
- P2-6: NextAuth `session.maxAge: 8 * 60 * 60` 추가 (`auth.ts`)
- P2-7: 인메모리 브루트포스 방어 추가 (`auth.ts`) — 5회 실패 시 15분 잠금, 성공 시 카운터 초기화
- P2-8: 연장·업그레이드 신청 목록 `status` 기본값 `undefined`(전체) → `REQUEST_STATUSES.REQUESTED` 변경; 결과 타입 `status?: number | "all"``status: number | "all"` 업데이트; 초기화 링크 `?status=1` 포함으로 변경
- P2-4: 이메일 중복 검사 추가 안 함 (기존 서비스 정책 유지)
- P2-9: SQL 파라미터 PII 로깅 정책 — 이름·이메일 등이 debug 레벨에서 노출됨. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 (별도 작업)
- P2-10: 변경 없음 (신청 시점 스냅샷 유지)
- AGENTS.md 인증 섹션 및 구현 phase 목록 갱신
각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다.
---
## 6. 검토했으나 문제 없음으로 확인한 사항
- 승인/취소/거절의 트랜잭션 구성과 `Status=1` 사전 확인(단일 요청 기준)은 규칙대로 구현됨.
- 업그레이드 승인이 `PlayerCount`를 변경하지 않음 — 규칙 준수.
- 메일 발송은 커밋 이후 수행 — 규칙 준수.
- 모든 API 라우트가 `withApiHandler``auth()` 검증 — 인증 우회 경로 없음
(`proxy.ts` matcher가 `/api/`를 제외하지만 핸들러 레벨에서 401 처리됨).
- 로그인 쿼리는 태그드 템플릿 파라미터 바인딩 사용 — SQL 인젝션 없음.
`PASSWORD(` 포함 쿼리는 SQL 로깅에서 파라미터 인라인을 생략함.
- 검색 파라미터는 zod `.catch()`로 방어되어 잘못된 입력이 500을 유발하지 않음.
- NextAuth 쿠키 SameSite=Lax + same-origin fetch 구조로 CSRF 위험은 낮음.
+339
View File
@@ -0,0 +1,339 @@
# chocoadmin 프로젝트 계획서
## 1. 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 서비스명 | chocoadmin |
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
---
## 2. 기술 스택
| 구분 | 기술 |
|---|---|
| 런타임 | Node.js (LTS 버전) |
| 프레임워크 | Next.js 15 (App Router) |
| 언어 | TypeScript |
| DB 클라이언트 | `mysql2` (MariaDB 호환) |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 환경변수 관리 | `.env.local` / `.env.production` |
---
## 3. CSS Framework 추천
관리자 화면은 주로 표(Table)와 폼(Form) 중심이므로, 빠른 개발과 유지보수성을 모두 고려한 3가지를 추천합니다.
### 3-1. Tailwind CSS + shadcn/ui ⭐ 추천
- **Tailwind CSS**: 유틸리티 클래스 기반으로 CSS 파일을 직접 작성할 일이 거의 없음
- **shadcn/ui**: Tailwind 기반의 복사-붙여넣기형 컴포넌트 라이브러리. Button, Table, Dialog, Form 등 관리자 UI에 필요한 컴포넌트가 이미 갖춰져 있음
- **장점**: Next.js App Router와 궁합이 뛰어남. 번들 사이즈가 작고, 컴포넌트 코드를 직접 소유하므로 커스터마이징이 자유로움
- **단점**: 초기 세팅이 다소 필요함
### 3-2. Ant Design (antd)
- React 생태계에서 오랫동안 검증된 전통적인 관리자 UI 라이브러리
- Table, Form, Modal, DatePicker 등 관리자 페이지에 필요한 컴포넌트가 모두 내장되어 있음
- **장점**: 별도 CSS 작업 없이 완성도 높은 UI를 빠르게 구성 가능
- **단점**: 번들 사이즈가 크고, 기본 디자인 톤에서 벗어나는 커스터마이징이 어려움
### 3-3. Mantine
- shadcn/ui보다 더 완결된 형태의 컴포넌트 세트 제공 (100개 이상)
- 자체 훅(hook) 라이브러리도 포함되어 있어 복잡한 상태 관리 없이 폼·테이블 구현 가능
- **장점**: 문서가 잘 정리되어 있고 TypeScript 지원이 뛰어남
- **단점**: antd처럼 디자인 자유도가 shadcn/ui보다 낮음
> **최종 추천: Tailwind CSS + shadcn/ui**
> Next.js 공식 생태계와 가장 잘 맞고, 장기적인 유지보수 측면에서 가장 유리합니다.
---
## 4. Table 라이브러리 추천
관리자 화면은 목록 조회, 검색, 정렬, 페이지네이션, 인라인 액션 버튼이 핵심이므로 아래 3가지를 추천합니다.
### 4-1. TanStack Table (React Table v8) ⭐ 추천
- Headless 방식(스타일 미포함)으로, Tailwind/shadcn과 조합해 완전한 커스터마이징 가능
- 정렬, 필터, 페이지네이션, 행 선택, 컬럼 고정 등 필요한 기능을 필요한 만큼만 추가
- **shadcn/ui DataTable**: TanStack Table + shadcn 조합의 공식 예제가 제공되어 빠른 시작 가능
- **장점**: 번들 사이즈 최소, 자유도 최대, Next.js App Router 완벽 호환
- **단점**: 기능을 직접 조립해야 하므로 초기 설정 비용이 있음
### 4-2. AG Grid (Community Edition)
- 대용량 데이터에 특화된 고성능 그리드 라이브러리
- 정렬, 필터, 그룹핑, 엑셀 내보내기, 인라인 편집 등 풍부한 기능이 내장
- 무료 Community Edition으로도 관리자 기능 구현에 충분
- **장점**: 기능이 가장 풍부하고 성능이 뛰어남
- **단점**: 번들 사이즈가 크고, 스타일 커스터마이징에 별도 CSS 작업이 필요
### 4-3. MUI DataGrid (Free tier)
- Material UI 기반의 데이터 그리드
- 정렬, 필터, 페이지네이션, 컬럼 재배치가 내장
- **장점**: Material UI 생태계와 자연스럽게 통합, 사용법이 직관적
- **단점**: Tailwind와 함께 사용 시 스타일 충돌 이슈가 발생할 수 있음. 고급 기능은 Pro 유료 플랜 필요
> **최종 추천: TanStack Table + shadcn/ui DataTable**
> shadcn/ui와 세트로 사용하면 코드 통일성이 높고, 필요한 기능만 붙일 수 있어 관리 부담이 적습니다.
---
## 5. 개발 환경 및 배포 환경
### 5-1. 개발 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS (DS920+, 20GB RAM) - Docker |
| DB (개발) | localhost MariaDB |
| 실행 방법 | `docker compose up` |
| 포트 | 3000 |
### 5-2. 서비스 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS (DS920+) - Docker |
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
| 접근 방식 | NAS에서 AWS EC2 DB에 직접 연결 (보안그룹 인바운드 규칙에 NAS 외부 IP 허용 필요) |
### 5-3. docker-compose.yml 구성 계획
```yaml
# docker-compose.yml (개발용)
services:
chocoadmin:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=host.docker.internal
- DB_PORT=3306
- DB_NAME=chocomae
- DB_USER=...
- DB_PASS=...
volumes:
- .:/app
- /app/node_modules
- /app/.next
```
### 5-4. Dockerfile 계획
```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]
```
---
## 6. 데이터베이스 주요 테이블 정리
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
| 테이블 | 설명 | 주요 컬럼 |
|---|---|---|
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime` |
| `maestro_extension` | 마에스트로 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
| `maestro_upgrade` | 마에스트로 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
| `maestro_log` | 마에스트로 처리 이력 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
### AccountType 코드표
| 값 | 의미 |
|---|---|
| 1 | 20명 (1만원) |
| 2 | 50명 (2만원) |
| 3 | 100명 (3만원) |
| 4 | 500명 (4만원) |
| 5 | 1,000명 (5만원) |
### ActivateStatus 코드표 (maestro)
| 값 | 의미 |
|---|---|
| 0 | 미승인 |
| 1 | 체험 |
| 2 | 활성화 |
| 100 | 등록취소 |
### Status 코드표 (maestro_extension / maestro_upgrade)
| 값 | 의미 |
|---|---|
| 1 | 요청 |
| 2 | 취소 |
| 3 | 연장/업그레이드 적용 |
---
## 7. 주요 화면 목록 및 API 설계
### 7-1. 화면 구조 (Next.js App Router)
```
app/
├── (auth)/
│ └── login/ # 관리자 로그인
├── (admin)/
│ ├── layout.tsx # 공통 사이드바 + 헤더
│ ├── maestro/
│ │ ├── page.tsx # 마에스트로 전체 목록
│ │ └── [id]/
│ │ └── page.tsx # 마에스트로 상세 정보
│ ├── extension/
│ │ └── page.tsx # 연장 신청 목록 + 승인/취소
│ └── upgrade/
│ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
└── api/
├── auth/[...nextauth]/
├── maestro/
│ ├── route.ts # GET: 목록 조회
│ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
├── extension/
│ ├── route.ts # GET: 목록 조회
│ └── [id]/route.ts # PATCH: 승인/취소
└── upgrade/
├── route.ts # GET: 목록 조회
└── [id]/route.ts # PATCH: 승인/거절
```
### 7-2. 화면별 기능 명세
#### [1] 마에스트로 전체 목록 (`/maestro`)
- 전체 마에스트로 목록 표시
- 검색: 이름, 이메일
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
- 정렬: 가입일, 만료일
- 컬럼: ID / 이름 / 이메일 / 계정유형 / 상태 / 인원수 / 사용가능일 / 상세보기
#### [2] 마에스트로 상세 정보 (`/maestro/[id]`)
- 기본 정보 (이름, 이메일, 계정 유형, 상태, 만료일)
- 플레이어(학생) 목록
- 연장 신청 이력
- 업그레이드 신청 이력
- 관리자 처리 로그
#### [3] 연장 신청 목록 (`/extension`)
- 연장 신청 목록 표시 (기본: Status=1 요청 건만)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인버튼 / 취소버튼
- 승인 시: `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime` 1년 연장, `maestro_extension.Status = 3`
- 취소 시: `maestro_extension.Status = 2`
#### [4] 업그레이드 신청 목록 (`/upgrade`)
- 업그레이드 신청 목록 표시 (기본: Status=1 요청 건만)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인버튼 / 거절버튼
- 승인 시: `maestro.AccountType` 변경, `maestro_upgrade.Status = 3`
- 거절 시: `maestro_upgrade.Status = 2`
---
## 8. 개발 단계 계획
### Phase 1. 프로젝트 초기화 및 환경 구성
- [ ] Next.js 15 + TypeScript 프로젝트 생성
- [ ] Tailwind CSS + shadcn/ui 설치 및 설정
- [ ] TanStack Table 설치
- [ ] mysql2 설치 및 DB 연결 유틸리티 작성
- [ ] docker-compose.yml 작성 (개발용)
- [ ] `.env.local` 환경변수 구성
- [ ] NextAuth.js v5 설치 및 `admin` 테이블 기반 로그인 구현
### Phase 2. 공통 레이아웃 및 인증
- [ ] 로그인 페이지 (`/login`)
- [ ] 인증 미들웨어 (비로그인 시 `/login` 리다이렉트)
- [ ] 공통 레이아웃 (사이드바 네비게이션, 상단 헤더)
### Phase 3. 마에스트로 관리
- [ ] 마에스트로 전체 목록 페이지 + API
- [ ] 마에스트로 상세 정보 페이지 + API
### Phase 4. 연장 신청 관리
- [ ] 연장 신청 목록 페이지 + API
- [ ] 연장 승인 / 취소 기능 + API
### Phase 5. 업그레이드 신청 관리
- [ ] 업그레이드 신청 목록 페이지 + API
- [ ] 업그레이드 승인 / 거절 기능 + API
### Phase 6. 배포 환경 구성
- [ ] Dockerfile (프로덕션용) 작성
- [ ] docker-compose.prod.yml 작성 (Synology NAS용)
- [ ] AWS EC2 DB 보안그룹 인바운드 설정 (NAS 외부 IP 허용)
- [ ] Synology NAS Container Manager에서 배포 테스트
---
## 9. 디렉토리 구조 (목표)
```
chocoadmin/
├── app/
│ ├── (auth)/
│ │ └── login/
│ ├── (admin)/
│ │ ├── layout.tsx
│ │ ├── maestro/
│ │ ├── extension/
│ │ └── upgrade/
│ └── api/
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ ├── data-table/ # TanStack Table 공통 컴포넌트
│ └── layout/ # 사이드바, 헤더
├── lib/
│ ├── db.ts # mysql2 연결 풀
│ └── utils.ts
├── types/
│ └── index.ts # DB 타입 정의
├── docs/
│ └── db-reference/
│ └── schema.sql
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── .env.local # (gitignore)
└── .env.production # (gitignore)
```
---
## 10. 보안 고려사항
- 관리자 전용 서비스이므로 외부 노출을 최소화 (NAS 내부 포트 또는 VPN 기반 접근 권장)
- `admin.Password`는 해시 검증 방식으로 NextAuth 연동 (기존 암호화 방식 확인 필요)
- DB 연결 정보는 `.env` 파일로 관리하며 Git에 포함하지 않음
- AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS IP로만 허용
---
*작성일: 2026-05-26*
+501
View File
@@ -0,0 +1,501 @@
# chocoadmin 개발 작업 계획
## 1. 프로젝트 개요
`chocoadmin`은 기존 `chocomae` 웹 서비스의 관리자 페이지와 관리자 기능만 독립 분리하는 신규 관리자 웹 서비스이다.
- 기존 서비스명: `chocomae`
- 기존 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
- 신규 서비스명: `chocoadmin`
- 신규 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/chocoadmin`
- 개발 목표: 기존 `mouse-typing` 서비스의 관리자 기능을 Next.js 기반 독립 서비스로 재구성
- 기존 운영 환경: AWS EC2 `t3a.medium` 인스턴스 1개
- 신규 개발/운영 플랫폼: Synology NAS Docker
- 보유 NAS: Synology DS920+, RAM 20GB
## 2. 핵심 개발 방향
1. 기존 `chocomae` DB를 그대로 사용한다.
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
3. 운영 DB 스키마를 임의 변경하지 않고, 기존 테이블과 상태값을 우선 존중한다.
4. 관리자 액션은 반드시 서버 사이드에서 권한 검증, 트랜잭션, 로그 기록을 거친다.
5. Synology NAS Docker에서 개발과 운영을 모두 재현 가능하게 구성한다.
## 3. 기술 스택 제안
### 기본 스택
- Runtime: Node.js LTS
- Framework: Next.js App Router
- Language: TypeScript
- Database: MariaDB
- DB Access: Prisma 또는 Drizzle ORM
- Auth: NextAuth/Auth.js 또는 자체 세션 기반 인증
- Validation: Zod
- Package Manager: pnpm
- Container: Docker, Docker Compose
### DB 접근 방식 추천
1. Prisma
- 장점: 타입 안정성, 마이그레이션/스키마 관리 경험이 좋고 개발 속도가 빠름
- 주의: 기존 MariaDB 스키마를 introspection해서 사용하는 방식이 적합함
2. Drizzle ORM
- 장점: SQL에 가까운 제어, 가벼움, 서버 액션/API Route와 잘 맞음
- 주의: 기존 DB 테이블 정의를 직접 코드로 옮기는 작업이 필요함
권장안: 초기 개발 생산성과 유지보수를 고려하면 Prisma를 우선 검토하고, 기존 DB 쿼리를 세밀하게 제어해야 하는 구간만 raw SQL을 병행한다.
## 4. CSS Framework 추천
관리자 서비스는 표, 검색, 필터, 상세 보기, 승인/거절 액션이 중심이므로 화려한 랜딩 페이지보다 유지보수성과 일관성이 중요하다.
### 1순위: Tailwind CSS + shadcn/ui
- 장점: Next.js와 궁합이 좋고, 관리자 화면에 필요한 버튼, 입력, 다이얼로그, 탭, 폼 구성이 빠름
- 장점: 컴포넌트 코드를 프로젝트 안에서 직접 소유하므로 장기 유지보수에 유리함
- 단점: Tailwind 클래스에 익숙해지는 시간이 필요함
- 추천 이유: `chocoadmin`처럼 작은 팀/개인 프로젝트에서 빠르게 만들고 나중에 고치기 좋음
### 2순위: Mantine
- 장점: 컴포넌트가 풍부하고 Form, Modal, Table 주변 UI를 빠르게 만들기 좋음
- 장점: 테마 시스템과 훅이 잘 갖춰져 있음
- 단점: 디자인을 많이 커스터마이징하면 프레임워크 스타일에 묶일 수 있음
- 추천 이유: 관리자 도구를 빠르게 안정적인 UI로 완성하기 좋음
### 3순위: Chakra UI
- 장점: 접근성, 컴포넌트 API, 테마 관리가 안정적임
- 장점: Next.js 프로젝트에서 사용 사례가 많음
- 단점: 대량 데이터 테이블은 별도 라이브러리 조합이 필요함
- 추천 이유: 익숙한 컴포넌트 기반 개발을 선호할 때 적합함
최종 권장안: `Tailwind CSS + shadcn/ui`를 기본 UI 프레임워크로 사용한다.
## 5. Table 라이브러리 추천
관리자 화면은 마에스트로 목록, 연장 신청, 업그레이드 신청처럼 대부분 표 기반으로 운영되므로 정렬, 필터, 검색, 페이지네이션, 행 선택, 컬럼 표시 제어, 액션 버튼 확장이 쉬워야 한다.
### 1순위: TanStack Table
- 장점: headless 방식이라 UI 프레임워크와 자유롭게 결합 가능
- 장점: 서버 사이드 페이지네이션, 정렬, 필터링을 구현하기 좋음
- 장점: shadcn/ui의 Data Table 패턴과 잘 맞음
- 단점: 직접 조립해야 하는 부분이 있어 초기 구현량이 있음
- 추천 이유: 관리자 기능이 늘어날 가능성이 높은 `chocoadmin`에 가장 유연함
### 2순위: AG Grid Community
- 장점: 엑셀 같은 고급 그리드 기능이 강함
- 장점: 컬럼 리사이즈, 고정 컬럼, 복잡한 필터 등 고급 기능이 풍부함
- 단점: UI가 무겁고, 일부 기능은 Enterprise 라이선스가 필요함
- 추천 이유: 향후 대량 데이터 운영 도구가 필요해질 때 적합함
### 3순위: Mantine React Table
- 장점: Mantine을 CSS/UI 프레임워크로 선택할 경우 통합 경험이 좋음
- 장점: TanStack Table 기반이라 기능 확장성이 좋음
- 단점: Mantine 생태계에 묶임
- 추천 이유: Mantine을 선택하는 경우 가장 빠르게 관리자 표를 만들 수 있음
최종 권장안: `TanStack Table + shadcn/ui Data Table` 조합을 사용한다.
## 6. DB 기준 관리자 대상 테이블
스키마 파일: `/Users/jisangs/work/jinaju/git/web/chocoadmin/docs/db-reference/schema.sql`
### 주요 테이블
- `admin`: 관리자 계정
- `maestro`: 마에스트로 계정
- `maestro_extension`: 마에스트로 연장 신청
- `maestro_upgrade`: 마에스트로 업그레이드 신청
- `maestro_log`: 마에스트로 관련 처리 로그
- `player`: 마에스트로에 속한 학생/플레이어
### 주요 상태값
`maestro.ActivateStatus`
- `0`: 미승인
- `1`: 체험
- `2`: 활성화
- `100`: 등록취소
`maestro.AccountType`
- `1`: 20명
- `2`: 50명
- `3`: 100명
- `4`: 500명
- `5`: 1000명
`maestro_extension.Status`
- `1`: 요청
- `2`: 취소
- `3`: 연장 적용
`maestro_upgrade.Status`
- `1`: 요청
- `2`: 취소
- `3`: 업그레이드 적용
## 7. 주요 관리자 화면 계획
### 7.1 공통 레이아웃
- 로그인 화면
- 좌측 사이드바
- 상단 관리자 정보 영역
- 공통 검색/필터 패턴
- 공통 확인 다이얼로그
- 처리 성공/실패 토스트
- 접근 권한 없는 경우 안내 화면
### 7.2 마에스트로 전체 목록
목표: 전체 마에스트로 계정을 검색, 필터링, 확인할 수 있는 운영 화면을 만든다.
주요 기능:
- 전체 목록 조회
- 이름, 이메일, `MaestroID` 검색
- 계정 종류 필터
- 활성 상태 필터
- 가입/약관 동의/사용 가능 기간 기준 정렬
- 학생 수 표시
- 상세 화면 이동
표 주요 컬럼:
- `MaestroID`
- `Name`
- `Email`
- `AccountType`
- `ActivateStatus`
- `PlayerCount`
- `AvailableActivateDateTime`
- `AcceptClausesDateTime`
- `AllowEditEnterCode`
### 7.3 마에스트로 상세 정보 보기
목표: 특정 마에스트로의 계정 상태와 관련 신청 이력을 한 화면에서 확인한다.
주요 기능:
- 기본 계정 정보
- 현재 계정 종류/활성 상태 표시
- 학생 수 및 플레이어 목록 요약
- 최근 연장 신청 이력
- 최근 업그레이드 신청 이력
- 처리 로그 표시
확장 후보 기능:
- 계정 상태 수동 변경
- 학생 목록 상세 조회
- 입장번호 변경 허용 여부 수정
단, 확장 후보 기능은 기존 관리자 기능과 운영 정책 확인 후 별도 구현한다.
### 7.4 마에스트로 연장 신청 목록
목표: `maestro_extension`의 연장 요청을 검토하고 승인 또는 취소 처리한다.
주요 기능:
- 연장 신청 목록 조회
- 요청 상태별 필터
- 요청일 기준 정렬
- 마에스트로 정보 함께 표시
- 연장 승인
- 연장 취소
- 처리 후 `maestro_log` 기록
승인 처리 초안:
1. `maestro_extension.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_extension.Status``3`으로 변경한다.
4. `maestro.AccountType`, `AvailableActivateDateTime`, `ActivateStatus` 갱신 규칙을 기존 서비스에서 확인해 동일하게 적용한다.
5. `maestro_log`에 처리 기록을 남긴다.
6. 트랜잭션을 커밋한다.
취소 처리 초안:
1. `maestro_extension.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_extension.Status``2`로 변경한다.
4. `maestro_log`에 취소 기록을 남긴다.
5. 트랜잭션을 커밋한다.
### 7.5 마에스트로 업그레이드 신청 목록
목표: `maestro_upgrade`의 업그레이드 요청을 검토하고 승인 또는 거절 처리한다.
주요 기능:
- 업그레이드 신청 목록 조회
- 요청 상태별 필터
- 현재 계정 종류와 요청 계정 종류 비교 표시
- 요청일 기준 정렬
- 업그레이드 승인
- 업그레이드 거절
- 처리 후 `maestro_log` 기록
승인 처리 초안:
1. `maestro_upgrade.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_upgrade.Status``3`으로 변경한다.
4. `maestro.AccountType``RequestedAccountType`으로 변경한다.
5. 필요 시 `PlayerCount` 정책을 기존 서비스에서 확인해 반영한다.
6. `maestro_log`에 처리 기록을 남긴다.
7. 트랜잭션을 커밋한다.
거절 처리 초안:
1. `maestro_upgrade.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_upgrade.Status``2`로 변경한다.
4. `maestro_log`에 거절 기록을 남긴다.
5. 트랜잭션을 커밋한다.
## 8. 애플리케이션 구조 제안
```text
chocoadmin/
app/
login/
maestros/
page.tsx
[maestroId]/
page.tsx
extension-requests/
page.tsx
upgrade-requests/
page.tsx
api/
components/
layout/
data-table/
forms/
dialogs/
lib/
auth/
db/
validators/
constants/
docs/
db-reference/
schema.sql
project-plan.md
prisma/
schema.prisma
docker/
docker-compose.yml
Dockerfile
```
## 9. Docker 개발/운영 계획
### 개발 환경
- Synology NAS Docker에서 Next.js 컨테이너 실행
- 개발 DB는 `localhost` MariaDB 사용
- `.env.local`로 DB 접속 정보 관리
- 개발 DB는 운영 DB 덤프에서 민감 정보를 제거한 샘플 데이터 사용 권장
### 운영 환경
- Synology NAS Docker에서 `chocoadmin` 컨테이너 실행
- 운영 DB는 기존 AWS EC2 MariaDB에 접속
- AWS 보안 그룹에서 Synology NAS 공인 IP만 MariaDB 접속 허용
- DB 계정은 관리자 서비스 전용 계정으로 분리
- 최소 권한 원칙 적용
- 운영 배포 시 환경변수는 Synology Container Manager 또는 `.env.production`으로 관리
### Docker 구성 초안
- `Dockerfile`: Next.js production build
- `docker-compose.yml`: app service 정의
- `healthcheck`: HTTP 상태 확인 엔드포인트 사용
- `restart: unless-stopped`
- 로그는 Docker 기본 로그 또는 Synology 로그 수집 방식 사용
## 10. 보안 계획
1. 관리자 로그인은 서버 세션 기반으로 구현한다.
2. 관리자 비밀번호 저장 방식은 기존 `admin.Password` 구조를 확인한 뒤 개선 가능성을 검토한다.
3. 운영 DB 접속 정보는 저장소에 커밋하지 않는다.
4. 승인/취소/거절 같은 변경 액션은 CSRF 또는 서버 액션 보호를 적용한다.
5. 관리자 액션은 `maestro_log`에 기록한다.
6. 운영 DB 직접 수정 기능은 최소화한다.
7. 승인/거절 API는 중복 처리 방지를 위해 현재 상태를 조건으로 검사한다.
8. 모든 변경 작업은 트랜잭션으로 처리한다.
## 11. 개발 단계별 작업 계획
### Phase 0. 기존 관리자 기능 조사
- 기존 `mouse-typing` 코드에서 관리자 라우트와 처리 로직 조사
- 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인
- 업그레이드 승인 시 `PlayerCount``AccountType` 처리 방식 확인
- 기존 `admin.Password` 검증 방식 확인
- 기존 로그 기록 형식 확인
완료 기준:
- 기존 관리자 처리 규칙을 문서화한다.
- 신규 서비스에서 재현해야 할 비즈니스 규칙을 확정한다.
### Phase 1. 프로젝트 초기화
- Next.js + TypeScript 프로젝트 생성
- ESLint, Prettier 설정
- Tailwind CSS + shadcn/ui 설정
- 기본 레이아웃 구성
- 환경변수 구조 정의
- Dockerfile, docker-compose 초안 작성
완료 기준:
- 로컬 또는 Synology Docker에서 기본 페이지가 실행된다.
### Phase 2. DB 연결 및 타입 구성
- MariaDB 연결 설정
- Prisma introspection 또는 Drizzle schema 작성
- DB 연결 헬퍼 구현
- 상태값 상수화
- 공통 날짜/계정 종류 표시 유틸 작성
완료 기준:
- 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회가 가능하다.
### Phase 3. 인증과 관리자 세션
- 로그인 화면 구현
- 관리자 계정 검증
- 세션 생성/만료 처리
- 보호 라우트 처리
- 로그아웃 구현
완료 기준:
- 로그인하지 않은 사용자는 관리자 화면에 접근할 수 없다.
- 로그인한 관리자만 목록과 상세 화면에 접근할 수 있다.
### Phase 4. 마에스트로 목록/상세 화면
- 마에스트로 목록 API 또는 서버 액션 구현
- TanStack Table 기반 목록 화면 구현
- 검색, 필터, 정렬, 페이지네이션 구현
- 상세 화면 구현
- 상세 화면에서 연장/업그레이드/로그 요약 표시
완료 기준:
- 운영자가 마에스트로를 빠르게 검색하고 상세 정보를 확인할 수 있다.
### Phase 5. 연장 신청 관리
- 연장 신청 목록 구현
- 상태 필터와 검색 구현
- 승인/취소 확인 다이얼로그 구현
- 승인/취소 서버 액션 구현
- 트랜잭션과 로그 기록 적용
완료 기준:
- 요청 상태의 연장 신청을 승인 또는 취소할 수 있다.
- 중복 처리와 이미 처리된 건의 재처리가 방지된다.
### Phase 6. 업그레이드 신청 관리
- 업그레이드 신청 목록 구현
- 현재/요청 계정 비교 표시
- 승인/거절 확인 다이얼로그 구현
- 승인/거절 서버 액션 구현
- 트랜잭션과 로그 기록 적용
완료 기준:
- 요청 상태의 업그레이드 신청을 승인 또는 거절할 수 있다.
- 처리 결과가 마에스트로 계정에 정확히 반영된다.
### Phase 7. 운영 배포 준비
- Synology NAS Docker Compose 운영 설정 작성
- 운영 환경변수 정리
- AWS EC2 MariaDB 접근 보안 그룹 설정 가이드 작성
- 백업/롤백 절차 작성
- 관리자 액션 테스트 체크리스트 작성
완료 기준:
- Synology NAS에서 운영 컨테이너를 실행할 수 있다.
- 운영 DB 연결 전 점검 항목이 문서화된다.
### Phase 8. 검증 및 안정화
- 주요 화면 수동 테스트
- 승인/취소/거절 트랜잭션 테스트
- 권한 없는 접근 테스트
- Docker 재시작 테스트
- 운영 DB read-only 리허설 후 실제 변경 기능 활성화
완료 기준:
- 핵심 관리자 업무가 신규 서비스에서 안정적으로 수행된다.
## 12. 우선 확인이 필요한 질문
1. 기존 `mouse-typing` 관리자 코드에서 연장 승인 시 정확히 며칠/몇 개월을 연장하는가?
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
3. `admin.Password`는 평문, 해시, 자체 암호화 중 어떤 방식인가?
4. 운영 MariaDB가 외부 접속을 허용할 수 있는 네트워크 구조인가?
5. 관리자 계정은 기존 `admin` 테이블을 그대로 사용할 것인가, 신규 인증 테이블을 둘 것인가?
6. 운영 초기에 읽기 전용 모드로 먼저 배포한 뒤 변경 기능을 켤 것인가?
## 13. 추천 MVP 범위
1차 MVP는 아래 기능까지만 포함한다.
- 관리자 로그인
- 마에스트로 전체 목록
- 마에스트로 상세 정보 보기
- 연장 신청 목록
- 연장 승인/취소
- 업그레이드 신청 목록
- 업그레이드 승인/거절
- 관리자 액션 로그 기록
- Docker 기반 실행
MVP 이후 확장 후보:
- 관리자 계정 관리
- 마에스트로 상태 직접 변경
- 학생 목록 상세 관리
- 데이터 내보내기 CSV
- 처리 통계 대시보드
- 감사 로그 전용 화면
## 14. 최종 추천 조합
- Framework: Next.js App Router
- Language: TypeScript
- UI/CSS: Tailwind CSS + shadcn/ui
- Table: TanStack Table
- DB: MariaDB 기존 `chocomae` DB
- ORM: Prisma 우선 검토
- Validation: Zod
- Runtime/Deploy: Docker on Synology NAS
- 운영 DB 연결: AWS EC2 MariaDB에 제한된 IP와 전용 계정으로 접속
+656
View File
@@ -0,0 +1,656 @@
# chocoadmin 프로젝트 계획서
## 1. 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 서비스명 | chocoadmin |
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
| 기존 운영 환경 | AWS EC2 t3a.medium 인스턴스 1개 |
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
---
## 2. 핵심 개발 방향
1. 기존 `chocomae` DB를 그대로 사용한다. DB 스키마는 임의로 변경하지 않는다.
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
3. 기존 테이블 구조와 상태값을 그대로 존중하고, 기존 서비스의 비즈니스 규칙을 먼저 파악한 뒤 동일하게 구현한다.
4. 승인·취소·거절 같은 변경 액션은 반드시 서버 사이드에서 권한 검증 → 트랜잭션 → 로그 기록 순서로 처리한다.
5. 중복 처리 방지를 위해 모든 변경 API는 현재 상태를 조건으로 먼저 검사한다.
---
## 3. 기술 스택
| 구분 | 기술 |
|---|---|
| 런타임 | Node.js LTS |
| 프레임워크 | Next.js 16 (App Router) |
| 언어 | TypeScript |
| 패키지 매니저 | pnpm |
| UI / CSS | Tailwind CSS + shadcn/ui |
| Table | TanStack Table (React Table v8) |
| DB 클라이언트 | mysql2 (MariaDB 호환) |
| ORM | Prisma (기존 DB introspection 방식으로 활용) |
| 유효성 검증 | Zod |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 이메일 발송 | Nodemailer (기존 `mouse-typing` SMTP 설정을 환경변수로 이관) |
| 환경변수 관리 | `.env.local` / `.env.production` |
### Prisma vs Drizzle ORM
두 가지 모두 MariaDB를 지원하나, 이 프로젝트에서는 **Prisma**를 우선 사용한다.
- 기존 DB를 `prisma db pull`(introspection)로 그대로 가져올 수 있어 초기 세팅이 빠름
- TypeScript 타입 자동 생성으로 컬럼명 오타를 컴파일 단계에서 잡을 수 있음
- 세밀한 SQL 제어가 필요한 구간은 `$queryRaw` / `$executeRaw`로 raw SQL 병행 가능
---
## 4. 개발 환경 및 배포 환경
### 4-1. 개발 환경
| 항목 | 내용 |
|---|---|
| 하드웨어 | 맥미니 |
| IDE | IntelliJ IDEA |
| 실행 방법 | IntelliJ 터미널에서 `pnpm dev` |
| 포트 | 3000 |
| DB (개발) | 맥미니 로컬 MariaDB (`localhost:3306`) |
| 환경변수 | `.env.local` |
### 4-2. 배포(서비스) 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS DS920+ (RAM 20GB) — Docker |
| 실행 방법 | Synology Container Manager (`docker compose up`) |
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
| DB 접근 | AWS 보안그룹에서 NAS 외부 IP만 3306 포트 허용 |
| DB 계정 | chocoadmin 전용 최소 권한 계정으로 분리 |
| 환경변수 | `.env.production` 또는 Container Manager 환경변수 설정 |
### 4-3. Dockerfile / docker-compose (배포용)
```dockerfile
# Dockerfile
FROM node:22-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
CMD ["pnpm", "start"]
```
```yaml
# docker-compose.yml
services:
chocoadmin:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://USER:PASS@AWS_EC2_HOST:3306/chocomae
- NEXTAUTH_SECRET=...
- NEXTAUTH_URL=http://NAS_IP:3000
restart: unless-stopped
```
---
## 5. 데이터베이스 주요 테이블
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
| 테이블 | 설명 | 주요 컬럼 |
|---|---|---|
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `PlayerCount` |
| `maestro_extension` | 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
| `maestro_upgrade` | 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
| `maestro_log` | 처리 이력 로그 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
### 상태값 코드표
**`maestro.AccountType`**
| 값 | 의미 |
|---|---|
| 1 | 20명 (1만원) |
| 2 | 50명 (2만원) |
| 3 | 100명 (3만원) |
| 4 | 500명 (4만원) |
| 5 | 1,000명 (5만원) |
**`maestro.ActivateStatus`**
| 값 | 의미 |
|---|---|
| 0 | 미승인 |
| 1 | 체험 |
| 2 | 활성화 |
| 100 | 등록취소 |
**`maestro_extension.Status` / `maestro_upgrade.Status`**
| 값 | 의미 |
|---|---|
| 1 | 요청 |
| 2 | 취소/거절 |
| 3 | 적용 완료 |
---
## 6. 애플리케이션 구조
```
chocoadmin/
├── app/
│ ├── (auth)/
│ │ └── login/
│ │ └── page.tsx # 관리자 로그인
│ ├── (admin)/
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
│ │ ├── maestros/
│ │ │ ├── page.tsx # 마에스트로 전체 목록
│ │ │ └── [maestroId]/
│ │ │ ├── page.tsx # 마에스트로 상세 정보 (server)
│ │ │ ├── MaestroEditForm.tsx # 기본 정보 수정 폼 (client)
│ │ │ ├── StudentsSection.tsx # 학생 목록 + 검색/페이지네이션 (client)
│ │ │ ├── ExtensionRequestsSection.tsx # 연장 신청 이력 + [연장] 버튼 (client)
│ │ │ └── UpgradeRequestsSection.tsx # 업그레이드 신청 이력 + 신청 UI (client)
│ │ ├── extension-requests/
│ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소
│ │ └── upgrade-requests/
│ │ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
│ └── api/
│ ├── auth/[...nextauth]/
│ ├── maestros/
│ │ ├── route.ts # GET: 목록
│ │ └── [id]/
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
│ │ ├── students/route.ts # GET: 학생 목록
│ │ ├── extension-requests/route.ts # POST: 연장 신청 등록 (관리자 직접)
│ │ ├── upgrade-requests/route.ts # POST: 업그레이드 신청 등록 (관리자 직접)
│ │ └── reset-password/route.ts # POST: 비밀번호 123456 초기화
│ ├── extension-requests/
│ │ └── [id]/route.ts # PATCH: 승인/취소
│ └── upgrade-requests/
│ └── [id]/route.ts # PATCH: 승인/거절
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ ├── data-table/ # TanStack Table 공통 컴포넌트
│ ├── layout/ # 사이드바, 헤더
│ ├── forms/
│ └── dialogs/ # 승인/취소 확인 다이얼로그
├── lib/
│ ├── db.ts # Prisma Client 싱글톤
│ ├── auth.ts # NextAuth 설정
│ ├── mail.ts # chocomae SMTP 메일 발송 모듈
│ ├── validators/ # Zod 스키마
│ └── constants.ts # 상태값 상수
├── types/
│ └── index.ts
├── prisma/
│ └── schema.prisma # DB introspection 결과
├── docs/
│ └── db-reference/
│ └── schema.sql
├── .env.local # gitignore
├── .env.production # gitignore
├── Dockerfile
└── docker-compose.yml
```
---
## 7. 주요 화면 기능 명세
### 7-1. 공통 레이아웃
- 로그인 화면 (미인증 시 `/login` 리다이렉트)
- 좌측 사이드바 네비게이션
- 공통 확인 다이얼로그 (승인/취소/거절 전 확인)
- 처리 성공/실패 토스트 알림
### 7-2. 마에스트로 전체 목록 (`/maestros`)
- 전체 목록 조회 (서버 사이드 페이지네이션)
- 검색: 이름, 이메일, MaestroID
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
- 필터: AccountType (20명 / 50명 / 100명 / 500명 / 1,000명)
- 정렬: 가입일, 사용 가능일
- 컬럼: ID / 이름 / 이메일 / 계정 유형 / 상태 / 학생 수 / 사용 가능일 / 상세보기
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`)
- 기본 계정 정보 (이름, 이메일, 계정 유형, 상태, 만료일, 약관 동의일)
- 기본 계정 정보 수정 및 저장
- 이름: 저장 시 다른 마에스트로 계정에서 이미 사용 중인지 검사하고, 중복이면 저장 실패 처리
- 이메일
- 상태: 콤보박스 (`maestro.ActivateStatus`)
- 계정유형: 콤보박스 (`maestro.AccountType`)
- 사용 가능일 (`maestro.AvailableActivateDateTime`)
- 입장코드 수정: 콤보박스 기반 입력/선택 UI
- 정보 수정 및 저장 성공 시 `maestro_log`에 처리 이력 기록
- 플레이어(학생) 전체 목록
- 서버 사이드 페이지네이션
- 이름 검색
- 연장 신청 이력 + [연장] 버튼
- 유료 요금제 계정에서만 버튼 활성화 (체험 계정 비활성)
- 클릭 시 현재 `AccountType`으로 `maestro_extension` INSERT (서버에서 DB 조회)
- 등록 후 이력 목록 즉시 갱신 (`router.refresh()`)
- 업그레이드 신청 이력 + 요금제 콤보박스 + [업그레이드] 버튼
- 콤보박스: 현재 요금제 이하 항목 비활성화, 상위 요금제만 선택 가능
- 클릭 시 선택한 `requestedAccountType`으로 `maestro_upgrade` INSERT (트랜잭션 내 서버 조회)
- 서버에서 `requestedAccountType > maestro.AccountType` 검증 (다운그레이드 차단)
- 등록 후 이력 목록 즉시 갱신 (`router.refresh()`)
- 관리자 처리 로그 (`maestro_log`)
### 7-4. 연장 신청 목록 (`/extension-requests`)
- 연장 신청 목록 조회 (기본 필터: Status=1 요청 건)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인 / 취소
**승인 처리 순서:**
1. `maestro_extension.Status = 1` 인지 확인 (중복 처리 방지)
2. 트랜잭션 시작
3. `maestro_extension.Status → 3`
4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용)
5. `maestro_log` 기록
6. 트랜잭션 커밋
7. 커밋 후 연장 완료 이메일 자동 발송
**연장 완료 이메일:**
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php:29`
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정 유효기간 연장 완료`
- 내용: 유효기간 +1년 연장 완료, 새 유효기간 안내
**취소 처리 순서:**
1. `maestro_extension.Status = 1` 인지 확인
2. 트랜잭션 시작
3. `maestro_extension.Status → 2`
4. `maestro_log` 기록
5. 트랜잭션 커밋
### 7-5. 업그레이드 신청 목록 (`/upgrade-requests`)
- 업그레이드 신청 목록 조회 (기본 필터: Status=1 요청 건)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인 / 거절
**승인 처리 순서:**
1. `maestro_upgrade.Status = 1` 인지 확인 (중복 처리 방지)
2. 트랜잭션 시작
3. `maestro_upgrade.Status → 3`
4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용)
5. `maestro_log` 기록
6. 트랜잭션 커밋
7. 커밋 후 업그레이드 완료 이메일 자동 발송
**업그레이드 완료 이메일:**
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php:30`
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
- 내용: 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년
**거절 처리 순서:**
1. `maestro_upgrade.Status = 1` 인지 확인
2. 트랜잭션 시작
3. `maestro_upgrade.Status → 2`
4. `maestro_log` 기록
5. 트랜잭션 커밋
---
## 8. 보안 고려사항
1. 관리자 로그인은 NextAuth 서버 세션 기반으로 구현한다.
2. 기존 `admin.Password` 저장 방식(평문/해시/자체 암호화)을 먼저 확인한 뒤 동일 방식 또는 해시 업그레이드를 결정한다.
3. 승인·취소·거절 액션은 Next.js Server Action 또는 API Route에서 CSRF 보호를 적용한다.
4. DB 연결 정보는 `.env` 파일로 관리하며 Git에 커밋하지 않는다.
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
8. SMTP/API 인증 정보, 발송자 주소, BCC 주소는 환경변수 또는 배포 시크릿으로 관리하고 코드에 하드코딩하지 않는다.
---
## 9. 개발 단계 계획
### Phase 0. 기존 관리자 코드 조사
> 기존 서비스 비즈니스 규칙을 파악해 재현 실수를 방지한다.
- [x] `mouse-typing` 관리자 라우트와 처리 로직 분석 (`src/web/admin/`, `src/web/server/admin/`)
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인 (몇 개월/년 연장인지)
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부 확인
- [x] `admin.Password` 검증 방식 확인 (평문 / MD5 / bcrypt 등)
- [x] `maestro_log` 기록 형식(`Type`, `Remark`) 확인
- [x] 조사 결과를 `docs/business-rules.md`에 문서화
### Phase 1. 프로젝트 초기화
- [x] `pnpm create next-app` (Next.js 16, TypeScript, App Router, Tailwind CSS)
- [x] shadcn/ui 초기화 (`pnpm dlx shadcn@latest init`)
- [x] TanStack Table 설치 (`pnpm add @tanstack/react-table`)
- [x] Prisma 설치 및 MariaDB introspection (`pnpm add prisma`, `prisma db pull`)
- [x] Zod 설치 (`pnpm add zod`)
- [x] NextAuth.js v5 설치
- [x] ESLint / Prettier 설정
- [x] `.env.local` 환경변수 구조 정의
- [x] IntelliJ에서 `pnpm dev` 실행 확인
### Phase 2. DB 연결 및 타입 구성
- [x] Prisma Client 싱글톤 (`lib/db.ts`)
- [x] 상태값 상수 정의 (`lib/constants.ts`)
- [x] 공통 유틸 작성 (날짜 포맷, AccountType 레이블 변환 등)
- [x] 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회 확인
### Phase 3. 인증 및 공통 레이아웃
- [x] 로그인 페이지 (`/login`)
- [x] NextAuth Credentials Provider — `admin` 테이블 기반 검증
- [x] 인증 프록시 (`proxy.ts`) — 비로그인 시 `/login` 리다이렉트
- [x] 공통 레이아웃 (`(admin)/layout.tsx`) — 사이드바, 헤더
- [x] 로그아웃 기능
### Phase 4. 마에스트로 목록 및 상세
- [x] 마에스트로 목록 API (`GET /api/maestros`) — 검색·필터·페이지네이션 포함
- [x] TanStack Table 기반 목록 화면
- [x] 마에스트로 상세 API (`GET /api/maestros/[id]`)
- [x] 상세 화면 — 연장/업그레이드 이력, 학생 목록 요약, 처리 로그
### Phase 5. 연장 신청 관리
- [x] 연장 신청 목록 API + 화면
- [x] 승인/취소 확인 다이얼로그
- [x] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log
- [x] 취소 API — 트랜잭션 + maestro_log
### Phase 6. 업그레이드 신청 관리
- [x] 업그레이드 신청 목록 API + 화면
- [x] 승인/거절 확인 다이얼로그
- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
- [x] 거절 API — 트랜잭션 + maestro_log
### Phase 7. 배포 환경 구성
- [x] Dockerfile (프로덕션용 pnpm 빌드)
- [x] `docker-compose.yml` (Synology NAS Container Manager용)
- [x] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용)
- [x] Synology NAS에서 컨테이너 실행 확인 (production + stage 동시 운영 중)
- [x] 운영 DB 연결 및 변경 기능 확인
### Phase 8. 검증 및 안정화
- [x] 승인/취소/거절 트랜잭션 시나리오 테스트 절차 문서화 (`docs/phase/phase8.md`)
- [x] 중복 처리 방지 코드 확인 (이미 처리된 건 재처리 시 `409` 응답)
- [x] 비인증 접근 차단 코드 확인 (`proxy.ts`, API route `auth()` 검사)
- [x] Docker 재시작 후 정상 동작 확인 절차 문서화 (`docs/phase/phase8.md`)
### Phase 9. 실행 로그 및 DB 쿼리 로그
> local/stage에서는 개발과 디버깅에 충분한 상세 로그를 남기고, production에서는 운영에 필요한 수준으로 로그 양과 민감정보 노출을 제한한다.
- [x] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`)
- [x] 환경 구분: `local`, `stage`, `production`
- [x] 로그 레벨 구분: `debug`, `info`, `warn`, `error`
- [x] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조
- [x] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드
- [x] 서버 실행 로그 유틸 구현
- [x] 공통 logger 모듈 추가 (`lib/logger.ts`)
- [ ] request 단위 correlation id 생성 및 로그에 포함 (보류 — Edge Middleware 제약으로 헤더 주입 방식 필요, `docs/phase/phase9.md` 참고)
- [x] API route 시작/종료, 처리 시간, status code, action 결과 기록 (`lib/api-handler.ts` `withApiHandler` 래퍼로 공통 처리)
- [x] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 (`ApiError` + `withApiHandler`)
- [x] DB query 로그 구현
- [x] Prisma query event 기반 query 로깅 적용 (`lib/db.ts` `$on("query", ...)`)
- [x] local/stage: query문, params, duration, 결과를 `sql-formatter`로 pretty 출력 (`$on` + `$extends.$allOperations`)
- [x] production: `LOG_LEVEL=info` 설정 시 debug 로그 전체 억제 — slow query만 warn으로 출력
- [x] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`, 기본 1000ms)
- [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 (미구현)
- [x] 환경변수 추가
- [x] `APP_ENV=local|stage|production`
- [x] `LOG_LEVEL=debug|info|warn|error`
- [ ] `DB_QUERY_LOG=off|summary|full` (미구현 — 현재는 `LOG_LEVEL`로 제어)
- [x] `DB_SLOW_QUERY_MS`
- [ ] `DB_LOG_RESULT_LIMIT` (미구현)
- [x] 주요 API에 로그 적용
- [x] 로그인 성공/실패 로그 (`auth.ts`)
- [x] 마에스트로 목록/상세 조회 로그 (`lib/maestros.ts`)
- [x] 연장 신청 조회/승인/취소 로그 (`lib/extension-requests.ts`, `app/api/extension-requests/[id]/route.ts`)
- [x] 업그레이드 신청 조회/승인/거절 로그 (`lib/upgrade-requests.ts`, `app/api/upgrade-requests/[id]/route.ts`)
- [x] 중복 처리 방지로 `409` 반환 시 warning 로그 (`withApiHandler``ApiError` 캐치 후 warn 출력)
- [x] Docker/NAS 로그 운영 절차 문서화 (`docs/phase/phase9.md`)
- [x] `docker compose logs -f` 확인 방법
- [x] stage와 production의 권장 `.env` 로그 설정 예시
- [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
- [x] 검증
- [x] local에서 query문과 결과 전체가 출력되는지 확인
- [x] stage에서 query문과 결과 전체가 출력되는지 확인
- [x] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인
- [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략)
### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
> `/maestros/[maestroId]` 상세 화면에서 관리자 계정 정보 수정과 학생 목록 조회를 완성한다.
- [x] 기존 `mouse-typing` 마에스트로 정보 수정 로직 조사
- [x] 이름 변경 시 중복 검사 기준 확인 (`maestro.Name`, 자기 자신 제외)
- [x] 이메일 변경 처리와 로그 형식 확인
- [x] 입장코드 수정 대상 컬럼과 기존 값 생성/검증 규칙 확인
- [x] `maestro_log.Type` / `Remark` 형식을 기존 서비스와 맞춤
- [x] 마에스트로 정보 수정 API 구현 (`PATCH /api/maestros/[id]`)
- [x] Zod 요청 스키마 정의: 이름, 이메일, 상태, 계정유형, 사용 가능일, 입장코드
- [x] 관리자 인증 및 권한 검증
- [x] 이름 중복 검사: 다른 계정이 사용 중이면 저장 실패 응답
- [x] 트랜잭션으로 `maestro` 정보 수정
- [x] 변경 전/후 값을 포함해 `maestro_log` 기록
- [x] 변경 없음, 유효하지 않은 상태값, 존재하지 않는 마에스트로에 대한 응답 정책 정리
- [x] 마에스트로 상세 화면 수정 폼 구현
- [x] 이름/이메일 입력
- [x] 상태 콤보박스
- [x] 계정유형 콤보박스
- [x] 사용 가능일 입력
- [x] 입장코드 수정 콤보박스
- [x] 저장 전 확인, 저장 중 비활성화, 성공/실패 토스트
- [x] 학생 목록 API 구현 (`GET /api/maestros/[id]/students`)
- [x] 전체 학생 목록 조회
- [x] 서버 사이드 페이지네이션
- [x] 이름 검색
- [x] 정렬 기준과 기본 page size 정의
- [x] 학생 목록 UI 구현
- [x] 이름 검색 입력
- [x] 페이지네이션 컨트롤
- [x] 빈 목록/검색 결과 없음 상태
- [x] 검증
- [x] 이름 중복 저장 실패 확인
- [x] 정보 수정 성공 시 DB 반영과 `maestro_log` 기록 확인
- [x] 학생 목록 페이지네이션/검색 동작 확인
- [x] 비인증 접근 차단 확인
### Phase 11. 이메일 발송 환경 및 자동 발송
> 기존 `chocomae` 서비스의 관리자 승인 완료 메일을 `chocoadmin`에서도 동일하게 발송한다.
> 상세 계획: `docs/phase/phase11.md`
- [x] 기존 이메일 발송 설정 이관 준비
- [x] 기존 서비스 파일 확인: `send_jinaju_mail.php` — SMTP gmail:465, SMTPS, 발송자/BCC 주소 확인
- [x] 기존 서비스 파일 확인: `mail_contents.php` — 연장/업그레이드 완료 이메일 제목·본문 확인
- [x] 기존 서비스 파일 확인: `mail_setting.php` — From, BCC, sender_name 확인
- [x] `.env.local`에 메일 환경변수 추가 (SMTP 비밀번호는 배포 시크릿으로 분리)
- [x] `nodemailer` 패키지 설치 (`pnpm add nodemailer && pnpm add -D @types/nodemailer`)
- [x] 메일 발송 모듈 구현 (`lib/mail.ts`)
- [x] `createTransporter()` — 환경변수 기반 SMTP transport
- [x] `sendMail()` — 공통 발송 함수 (`MAIL_SEND_ENABLED` 체크, From/BCC/ReplyTo 구성, 성공·실패 로그)
- [x] `sendExtensionDoneEmail()` — 연장 완료 이메일 템플릿
- [x] `sendUpgradeDoneEmail()` — 업그레이드 완료 이메일 템플릿 (체험→유료 / 유료→유료 내부 분기)
- [x] `lib/extension-requests.ts` 수정
- [x] `approveExtensionRequest` 반환값에 `maestroName`, `maestroEmail` 추가 (이미 `include: { maestro: true }` 있음 — 추가 쿼리 불필요)
- [x] `lib/upgrade-requests.ts` 수정
- [x] `getActionTarget``include: { maestro: { select: { Name, Email } } }` 추가
- [x] `approveUpgradeRequest` 반환값에 `maestroName`, `maestroEmail`, `registeredActivateStatus`, `registeredAccountType`, `requestedAccountType` 추가
- [x] 승인 API에 이메일 자동 발송 연결
- [x] `app/api/extension-requests/[id]/route.ts` — 승인 후 `void sendExtensionDoneEmail(...)` 호출
- [x] `app/api/upgrade-requests/[id]/route.ts` — 승인 후 `void sendUpgradeDoneEmail(...)` 호출
- [x] 메일 발송 실패 정책 적용: 실패 시 `logger.error`만 기록, 승인 결과(200)는 그대로 반환 (롤백 없음)
- [x] 검증
- [x] `MAIL_SEND_ENABLED=false`에서 승인 시 `send skipped` 로그 확인, 승인 결과 200 정상
- [x] `MAIL_SEND_ENABLED=true`에서 연장 승인 후 이메일 제목/본문/수신자/BCC 확인
- [ ] 업그레이드 승인 후 체험→유료 케이스: `0원 : 무료 체험 기간` 본문 확인
- [ ] 업그레이드 승인 후 유료→유료 케이스: `{prev_price}원` 본문 확인
- [x] SMTP 오류 시 승인 API 200 응답 + `error: send failed` 로그 확인
### Phase 12. 관리자 직접 연장/업그레이드 신청 등록
> 마에스트로 상세 화면에서 관리자가 직접 연장·업그레이드 신청을 DB에 등록할 수 있도록 한다. 메일 발송은 하지 않는다.
- [x] 연장 신청 등록 API 구현 (`POST /api/maestros/[id]/extension-requests`)
- [x] 서버에서 `maestro.AccountType` 직접 조회 — 클라이언트 값 신뢰 안 함
- [x] 유료 요금제 계정이 아닌 경우 400 반환 (체험 계정 차단)
- [x] `maestro_extension` INSERT (`AccountType`, `RequestedDateTime`, `Status=1`)
- [x] 업그레이드 신청 등록 API 구현 (`POST /api/maestros/[id]/upgrade-requests`)
- [x] 트랜잭션 내에서 `maestro` SELECT → `maestro_upgrade` INSERT (원자성 보장)
- [x] `requestedAccountType > maestro.AccountType` 검증 (다운그레이드 차단, 400 반환)
- [x] `RegisteredActivateStatus`, `RegisteredAccountType` 서버 DB 조회값으로 저장
- [x] 연장 신청 이력 클라이언트 컴포넌트 (`ExtensionRequestsSection.tsx`)
- [x] 섹션 우상단 [연장] 버튼
- [x] 체험 계정 (`AccountType``PAID_ACCOUNT_TYPE_VALUES`) 시 버튼 비활성화
- [x] 등록 성공 후 `router.refresh()`로 이력 테이블 즉시 갱신
- [x] 업그레이드 신청 이력 클라이언트 컴포넌트 (`UpgradeRequestsSection.tsx`)
- [x] 섹션 우상단 요금제 콤보박스 + [업그레이드] 버튼
- [x] 현재 요금제 이하 옵션 `disabled`, 상위 요금제만 선택 가능
- [x] 선택 후 등록 성공 시 콤보박스 초기화 + `router.refresh()`
- [x] `PAID_ACCOUNT_TYPE_VALUES` 공용 상수로 추출 (`lib/constants.ts`)
- [x] 기존 `lib/maestros.ts`의 로컬 정의 제거 및 import로 교체
- [x] 두 신규 API route의 로컬 정의 제거 및 import로 교체
### Phase 13. 보안·정합성 수정
> AGENTS.md "Implemented phases" Phase 13 항목 참조.
- [x] 모든 항목 완료 (P0-1 ~ P2-8)
### Phase 14. 비밀번호 초기화 및 배포 스크립트
> 마에스트로 계정 비밀번호 초기화 기능을 추가한다.
> 완료 날짜: 2026-07-05
- [x] 비밀번호 초기화 API 구현 (`POST /api/maestros/[id]/reset-password`)
- [x] `resetMaestroPassword(maestroID)` 추가 (`lib/maestros.ts`)
- [x] `$transaction` 내에서 `UPDATE maestro SET Password = PASSWORD('123456')` 실행
- [x] `reset_maestro_password` 타입으로 `maestro_log` 기록
- [x] `withApiHandler`로 인증·에러 처리 공통화
- [x] 비밀번호 초기화 버튼 UI (`MaestroEditForm.tsx`)
- [x] `[암호 초기화]` 버튼을 `[저장]` 버튼 우측에 추가 (`variant="outline"`, `KeyRound` 아이콘)
- [x] `window.confirm`으로 `{마에스트로명} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?` 확인
- [x] 성공 시 기존 메시지 영역에 `암호 초기화 완료` 표시
### Phase 15. Production/Stage 배포 환경 안정화
> Production 첫 배포 과정에서 발견된 이슈들을 해결하고 NAS 배포 자동화 스크립트를 추가한다.
> 완료 날짜: 2026-07-05
- [x] Production 배포 경로 생성 및 git pull 방식 배포 설정
- Production: `/volume1/docker/service/jinaju/chocoadmin` + `docker-compose.yml`
- Stage: 동일 경로, `-f docker-compose.stage.yml` 로 구분
- [x] **포트 충돌 수정** (`docker-compose.yml`)
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"``expose: "3000"` 으로 변경
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
- `services: chocoadmin:``services: chocoadmin-stage:` 으로 변경
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
- `auth.ts`: `cookies.sessionToken.name = "chocoadmin-${APP_ENV}.session-token"` 추가
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
- 인라인 `"use server"` 클로저는 빌드마다 새 ID 부여 → 재배포 후 `Failed to find Server Action`
- 코드베이스 전체 인라인 서버 액션 검토 및 동일 패턴 수정 완료
- [x] Production/Stage 동시 운영 정상 확인
- [x] 배포 자동화 스크립트 추가 (`scripts/`)
- [x] `scripts/deploy-stage.sh` — git pull → proxy-network 확인 → `docker compose -f docker-compose.stage.yml up -d --build --remove-orphans` → NPM reload
- [x] `scripts/deploy-production.sh``yes` 입력 확인 후 동일 5단계 실행 (`docker-compose.yml` 사용)
- [x] 각 스크립트는 NAS 해당 디렉토리에서 직접 실행
### Phase 16. 반응형 웹 디자인
> 모바일 화면(360~430px)에서도 관리자 패널을 정상 사용할 수 있도록 사이드바 내비게이션, 표 overflow, 페이지네이션 정렬을 개선한다.
> 완료 날짜: 2026-07-07
- [x] 모바일 사이드바
- [x] `app/(admin)/nav-config.ts` 생성 — navItems 배열을 `layout.tsx``MobileNav.tsx`가 공유
- [x] `app/(admin)/MobileNav.tsx` 생성 — 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), 슬라이드인 패널(X 버튼), ESC 키 지원, pathname 변경 시 자동 닫힘, body 스크롤 잠금, CSS `translate-x` 트랜지션 애니메이션
- [x] `app/(admin)/layout.tsx` 수정 — 헤더에 `<MobileNav />` 통합; 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지
- [x] 표 overflow 수정 (`ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx`)
- 원인: 두 섹션은 서버 props로 데이터를 받아 SSR 초기 렌더에 `min-w-[560px]` 표가 포함됨 → `section``overflow` 미설정(`visible` 기본값)이어서 표가 섹션을 밀어냄 → `overflow-x-auto`가 확장된 너비를 그대로 가져가 스크롤 미발생. `StudentsSection`·`LogsSection`은 클라이언트 fetch 방식이라 SSR 초기 렌더에 표가 없어 동일 패턴으로도 정상 동작.
- [x] `<section>``overflow-hidden` 추가 — BFC 확립으로 섹션 너비를 뷰포트 기준으로 고정
- [x] 섹션 헤더를 `flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between`으로 변경 — 모바일에서 세로 스택, 액션 버튼 접근성 확보
- [x] 페이지네이션 오른쪽 정렬 — `<nav>``self-end sm:self-auto` 추가 (모바일 `flex-col`에서 오른쪽 정렬, 데스크탑 `flex-row`에서 기존 동작 유지)
- [x] `app/(admin)/extension-requests/page.tsx`
- [x] `app/(admin)/upgrade-requests/page.tsx`
- [x] `app/(admin)/maestros/page.tsx`
- [x] `app/(admin)/maestros/[maestroId]/StudentsSection.tsx`
- [x] `app/(admin)/maestros/[maestroId]/LogsSection.tsx`
---
## 10. MVP 범위
**1차 MVP (위 Phase 0~7):**
- 관리자 로그인
- 마에스트로 전체 목록 및 상세 정보 보기
- 연장 신청 목록 / 승인 / 취소
- 업그레이드 신청 목록 / 승인 / 거절
- 관리자 액션 로그 기록
- Docker 기반 NAS 배포
**MVP 이후 확장 후보:**
- 학생 목록 상세 관리
- 이메일 발송 이력/재발송 관리
- 데이터 내보내기 (CSV)
- 처리 통계 대시보드
- 감사 로그 전용 화면
- 관리자 계정 관리
---
## 11. 구현 전 확인이 필요한 사항
기존 `mouse-typing` 서비스 코드(`src/web/server/admin/`)에서 반드시 확인해야 할 비즈니스 규칙들이다.
1. 연장 승인 시 `AvailableActivateDateTime`을 정확히 얼마나 연장하는가? (1년? 계정 유형별 다름?)
: 계정 유형별로 차이 없고, 1년 연장
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
: AccountType만 변경
3. `admin.Password`는 어떤 방식으로 저장/검증되는가? (평문, MD5, bcrypt 등)
: MariaDB의 Password() 함수를 이용해서 저장/검증됨
4. `maestro_log``Type` 컬럼에 어떤 값들이 사용되는가? (`Remark` 형식 포함)
: 다음과 같은 값들이 사용됨
* add_maestro
* update_maestro
* add_maestro
* register_maestro
* update_maestro_name
* extension_maestro
* upgrade_maestro
* update_maestro_email
5. 운영 MariaDB가 외부 IP 접속을 허용할 수 있는 네트워크 구조인가?
: 외부 IP 접속을 허용하고 있다
---
*작성일: 2026-05-26*
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { ApiError } from "@/lib/errors";
import { logger } from "@/lib/logger";
export type ApiHandlerContext<P> = {
params: Promise<P>;
t0: number;
};
export function withApiHandler<P = Record<string, never>>(
ctx: string,
handler: (req: Request, ctx: ApiHandlerContext<P>) => Promise<NextResponse>
) {
return async (
request: Request,
routeContext?: { params: Promise<P> }
): Promise<NextResponse> => {
const t0 = Date.now();
const session = await auth();
if (!session) {
logger.warn(ctx, "unauthorized");
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const handlerCtx: ApiHandlerContext<P> = {
params: routeContext?.params ?? (Promise.resolve({}) as Promise<P>),
t0,
};
try {
return await handler(request, handlerCtx);
} catch (error) {
if (error instanceof ApiError) {
const level = error.statusCode >= 500 ? "error" : "warn";
logger[level](ctx, error.message, {
status: error.statusCode,
duration: Date.now() - t0,
});
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
logger.error(ctx, "unexpected error", {
error: String(error),
duration: Date.now() - t0,
});
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
);
}
};
}
+89
View File
@@ -0,0 +1,89 @@
export const ACCOUNT_TYPES = {
BASIC_20: 1,
STANDARD_50: 2,
PRO_100: 3,
SCHOOL_500: 4,
SCHOOL_1000: 5,
STUDENT_TRIAL: 100,
MAESTRO_TRIAL: 101,
} as const;
export const ACCOUNT_TYPE_LABELS = {
[ACCOUNT_TYPES.BASIC_20]: "20명 (1만원)",
[ACCOUNT_TYPES.STANDARD_50]: "50명 (2만원)",
[ACCOUNT_TYPES.PRO_100]: "100명 (3만원)",
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (4만원)",
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (5만원)",
[ACCOUNT_TYPES.STUDENT_TRIAL]: "학생체험",
[ACCOUNT_TYPES.MAESTRO_TRIAL]: "마에체험",
} as const;
export const TRIAL_ACCOUNT_TYPE_LABELS = {
[ACCOUNT_TYPES.BASIC_20]: "20명 (0원)",
[ACCOUNT_TYPES.STANDARD_50]: "50명 (0원)",
[ACCOUNT_TYPES.PRO_100]: "100명 (0원)",
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (0원)",
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (0원)",
} as const;
export const ACCOUNT_TYPE_PLAYER_COUNTS = {
[ACCOUNT_TYPES.BASIC_20]: 20,
[ACCOUNT_TYPES.STANDARD_50]: 50,
[ACCOUNT_TYPES.PRO_100]: 100,
[ACCOUNT_TYPES.SCHOOL_500]: 500,
[ACCOUNT_TYPES.SCHOOL_1000]: 1000,
} as const;
export const ACCOUNT_TYPE_PRICES = {
[ACCOUNT_TYPES.BASIC_20]: 10000,
[ACCOUNT_TYPES.STANDARD_50]: 20000,
[ACCOUNT_TYPES.PRO_100]: 30000,
[ACCOUNT_TYPES.SCHOOL_500]: 40000,
[ACCOUNT_TYPES.SCHOOL_1000]: 50000,
} as const;
export const ACTIVATE_STATUSES = {
PENDING: 0,
TRIAL: 1,
ACTIVE: 2,
CANCELED: 100,
} as const;
export const ACTIVATE_STATUS_LABELS = {
[ACTIVATE_STATUSES.PENDING]: "미승인",
[ACTIVATE_STATUSES.TRIAL]: "체험",
[ACTIVATE_STATUSES.ACTIVE]: "활성화",
[ACTIVATE_STATUSES.CANCELED]: "등록 취소",
} as const;
export const REQUEST_STATUSES = {
REQUESTED: 1,
CLOSED: 2,
APPLIED: 3,
} as const;
export const REQUEST_STATUS_LABELS = {
[REQUEST_STATUSES.REQUESTED]: "요청",
[REQUEST_STATUSES.CLOSED]: "취소",
[REQUEST_STATUSES.APPLIED]: "적용 완료",
} as const;
export const PAID_ACCOUNT_TYPE_VALUES = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[keyof typeof ACCOUNT_TYPES];
export type PaidAccountType =
| typeof ACCOUNT_TYPES.BASIC_20
| typeof ACCOUNT_TYPES.STANDARD_50
| typeof ACCOUNT_TYPES.PRO_100
| typeof ACCOUNT_TYPES.SCHOOL_500
| typeof ACCOUNT_TYPES.SCHOOL_1000;
export type ActivateStatus =
(typeof ACTIVATE_STATUSES)[keyof typeof ACTIVATE_STATUSES];
export type RequestStatus =
(typeof REQUEST_STATUSES)[keyof typeof REQUEST_STATUSES];
+266
View File
@@ -0,0 +1,266 @@
import { writeSync } from "fs";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { format } from "sql-formatter";
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
import { logger, timestamp } from "@/lib/logger";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
// Docker 파이프 환경에서 stdout fd는 non-blocking이라 writeSync가 부분 쓰기를
// 하거나 EAGAIN을 던질 수 있다. 전부 쓸 때까지 반복해 로그 유실을 막는다.
function writeAll(fd: number, text: string): void {
const buf = Buffer.from(text, "utf8");
let offset = 0;
while (offset < buf.length) {
try {
offset += writeSync(fd, buf, offset, buf.length - offset);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EAGAIN") continue;
throw err;
}
}
}
// ── SQL 로깅 ($on) ────────────────────────────────────────────────
function inlineParams(query: string, rawParams: string): string {
try {
const params = JSON.parse(rawParams) as unknown[];
let idx = 0;
return query.replace(/\?/g, () => {
const val = params[idx++];
if (val === null || val === undefined) return "NULL";
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
if (typeof val === "boolean") return val ? "1" : "0";
return String(val);
});
} catch {
return query;
}
}
function onQuery(e: Prisma.QueryEvent): void {
const isSlow = e.duration >= SLOW_QUERY_MS;
const isSensitive = e.query.includes("PASSWORD(");
const sqlWithParams = isSensitive ? e.query : inlineParams(e.query, e.params);
if (isSlow) {
const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 });
const ts = timestamp();
const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n");
writeAll(2, `[${ts}] [WARN ] [db:query] ${e.duration}ms [SLOW]\n${indented}\n`);
return;
}
if (logger.isEnabled("debug")) {
const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 });
const ts = timestamp();
const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n");
writeAll(1, `[${ts}] [DEBUG] [db:query] ${e.duration}ms\n${indented}\n`);
return;
}
if (logger.isEnabled("info")) {
const sql = sqlWithParams.replace(/\s+/g, " ").trim();
const ts = timestamp();
writeAll(1, `[${ts}] [INFO ] [db:query] ${e.duration}ms ${sql}\n`);
}
}
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
const COL_MAX_WIDTH = 40;
const SENSITIVE_KEYS = new Set(["Password", "password"]);
function safeValue(key: string, value: unknown): string {
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return "NULL";
if (typeof v === "bigint") return v.toString();
if (v instanceof Date) return v.toISOString();
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
function isWide(cp: number): boolean {
return (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
(cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
(cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
cp >= 0x20000 // CJK Extension B+
);
}
function displayWidth(s: string): number {
let w = 0;
for (const char of s) {
w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
}
return w;
}
function truncateStr(s: string, maxWidth: number): string {
if (displayWidth(s) <= maxWidth) return s;
let w = 0;
let result = "";
for (const char of s) {
const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
w += cw;
result += char;
}
return result + "…";
}
function padEnd(s: string, width: number): string {
const truncated = truncateStr(s, width);
return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
}
function renderTable(headers: string[], rows: string[][]): string {
const colWidths = headers.map((h, i) =>
Math.min(
COL_MAX_WIDTH,
Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
)
);
const rule = (l: string, m: string, r: string, s: string) =>
l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
const dRows = rows.map(
(r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
);
return [
rule("┌", "┬", "┐", "─"),
hRow,
rule("├", "┼", "┤", "─"),
...dRows,
rule("└", "┴", "┘", "─"),
]
.map((l) => ` ${l}`)
.join("\n");
}
function buildResultLog(
model: string | undefined,
operation: string,
result: unknown
): string {
const label = model ? `${model}.${operation}` : operation;
if (typeof result === "number") {
return `${label}${result}`;
}
if (result !== null && typeof result === "object" && "count" in result) {
return `${label} → count: ${(result as { count: number }).count}`;
}
if (Array.isArray(result)) {
const total = result.length;
if (total === 0) return `${label} → 0 rows`;
const preview = result.slice(0, 5);
const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
if (typeof preview[0] === "object" && preview[0] !== null) {
const objRows = preview as Record<string, unknown>[];
const headers = Object.keys(objRows[0]);
const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
return `${label}${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
}
const rows = preview.map((v) => [formatValue(v)]);
return `${label}${countLabel}\n${renderTable(["value"], rows)}${moreLabel}`;
}
if (result !== null && typeof result === "object") {
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
k,
safeValue(k, v),
]);
return `${label}\n${renderTable(["field", "value"], rows)}`;
}
return `${label}${String(result)}`;
}
function logResult(
model: string | undefined,
operation: string,
result: unknown
): void {
if (!logger.isEnabled("debug")) return;
const ts = timestamp();
const body = buildResultLog(model, operation, result);
writeAll(1, `[${ts}] [DEBUG] [db:result] ${body}\n`);
}
// ── PrismaClient 생성 ─────────────────────────────────────────────
function createPrismaClient() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required to initialize Prisma Client.");
}
const adapter = new PrismaMariaDb(databaseUrl);
const baseClient = new PrismaClient({
adapter,
log: [{ emit: "event", level: "query" }],
});
baseClient.$on("query", onQuery);
const extendedClient = baseClient.$extends({
query: {
$allOperations: async ({ model, operation, args, query }) => {
try {
const result = await query(args);
logResult(model, operation, result);
return result;
} catch (err) {
const label = model ? `${model}.${operation}` : operation;
const msg = err instanceof Error ? err.message : String(err);
const ts = timestamp();
writeAll(2, `[${ts}] [ERROR] [db:error] ${label} failed: ${msg}\n`);
throw err;
}
},
},
});
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
return extendedClient as unknown as PrismaClient;
}
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
+9
View File
@@ -0,0 +1,9 @@
export class ApiError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ApiError";
}
}
+367
View File
@@ -0,0 +1,367 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
calculateExtendedAvailableDate,
getAccountTypeLabel,
} from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const;
const extensionRequestSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED),
});
type PageSize = (typeof pageSizeOptions)[number];
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type ExtensionRequestSearchParams = z.infer<
typeof extensionRequestSearchSchema
>;
export type ExtensionRequestListItem = {
maestroExtensionID: number;
maestroID: number;
maestroName: string;
requestedAccountType: number;
currentAccountType: number;
activateStatus: number;
requestedDateTime: string;
availableActivateDateTime: string;
status: number;
};
export type ExtensionRequestListResult = {
items: ExtensionRequestListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
status: number | "all";
};
export async function getExtensionRequests(
rawSearchParams: RawSearchParams = {}
): Promise<ExtensionRequestListResult> {
const t0 = Date.now();
const params = parseExtensionRequestSearchParams(rawSearchParams);
const where = buildExtensionRequestWhere(params);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, requests] = await Promise.all([
db.maestro_extension.count({ where }),
db.maestro_extension.findMany({
where,
orderBy: { MaestroExtensionID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroExtensionID: true,
MaestroID: true,
AccountType: true,
RequestedDateTime: true,
Status: true,
maestro: {
select: {
Name: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
},
},
},
}),
]);
logger.info("extension-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return {
items: requests.map(toExtensionRequestListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
q: params.q,
status: params.status,
};
}
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}> {
return db.$transaction(async (tx) => {
// Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: {
select: {
Name: true,
Email: true,
AvailableActivateDateTime: true,
ActivateStatus: true,
},
},
},
});
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
}
const availableActivateDateTime = calculateExtendedAvailableDate(
request!.maestro.AvailableActivateDateTime
);
await tx.maestro.update({
where: { MaestroID: request!.MaestroID },
data: {
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime,
},
});
await tx.maestro_extension.updateMany({
where: {
MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED,
},
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_log.create({
data: {
Type: "extension_maestro",
MaestroID: request!.MaestroID,
LogDateTime: new Date(),
Remark: buildExtensionLogRemark(
request!.maestro.Name,
request!.AccountType
),
},
});
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request!.maestro.Name.trim(),
maestroEmail: request!.maestro.Email.trim(),
};
});
}
export async function createExtensionRequest(
maestroID: number
): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> {
return db.$transaction(async (tx) => {
const maestro = await tx.maestro.findUnique({
where: { MaestroID: maestroID },
select: { AccountType: true, ActivateStatus: true, Name: true },
});
if (!maestro) {
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
}
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) {
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400);
}
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400);
}
const request = await tx.maestro_extension.create({
data: {
MaestroID: maestroID,
AccountType: maestro.AccountType,
RequestedDateTime: new Date(),
Status: REQUEST_STATUSES.REQUESTED,
},
select: {
MaestroExtensionID: true,
RequestedDateTime: true,
Status: true,
},
});
await tx.maestro_log.create({
data: {
Type: "request_extension_maestro",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: buildExtensionLogRemark(maestro.Name, maestro.AccountType),
},
});
return {
maestroExtensionID: request.MaestroExtensionID,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
};
});
}
export async function cancelExtensionRequest(
maestroExtensionID: number
): Promise<void> {
await db.$transaction(async (tx) => {
const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.CLOSED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: { select: { Name: true } },
},
});
await tx.maestro_log.create({
data: {
Type: "cancel_extension_maestro",
MaestroID: request!.MaestroID,
LogDateTime: new Date(),
Remark: buildExtensionLogRemark(
request!.maestro.Name,
request!.AccountType
),
},
});
});
}
export function parseExtensionRequestSearchParams(
rawSearchParams: RawSearchParams
): ExtensionRequestSearchParams {
return extensionRequestSearchSchema.parse(
searchParamsToRecord(rawSearchParams)
);
}
function buildExtensionRequestWhere(
params: ExtensionRequestSearchParams
): Prisma.maestro_extensionWhereInput {
const conditions: Prisma.maestro_extensionWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestro_extensionWhereInput[] = [
{ maestro: { Name: { contains: params.q } } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift(
{ MaestroExtensionID: numericQuery },
{ MaestroID: numericQuery }
);
}
conditions.push({ OR: keywordConditions });
}
if (typeof params.status === "number" && Number.isInteger(params.status)) {
conditions.push({ Status: params.status });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toExtensionRequestListItem(request: {
MaestroExtensionID: number;
MaestroID: number;
AccountType: number;
RequestedDateTime: Date;
Status: number;
maestro: {
Name: string;
AccountType: number;
ActivateStatus: number;
AvailableActivateDateTime: Date;
};
}): ExtensionRequestListItem {
return {
maestroExtensionID: request.MaestroExtensionID,
maestroID: request.MaestroID,
maestroName: request.maestro.Name.trim(),
requestedAccountType: request.AccountType,
currentAccountType: request.maestro.AccountType,
activateStatus: request.maestro.ActivateStatus,
requestedDateTime: request.RequestedDateTime.toISOString(),
availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status,
};
}
function buildExtensionLogRemark(maestroName: string, accountType: number) {
return `${maestroName.trim()}(${getAccountTypeLabel(accountType)})`.slice(
0,
100
);
}
+65
View File
@@ -0,0 +1,65 @@
type Level = "debug" | "info" | "warn" | "error";
const PRIORITY: Record<Level, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
function getMinLevel(): Level {
const env = process.env.LOG_LEVEL as Level | undefined;
if (env && env in PRIORITY) return env;
const appEnv = process.env.APP_ENV;
return appEnv === "production" ? "info" : "debug";
}
function shouldLog(level: Level): boolean {
return PRIORITY[level] >= PRIORITY[getMinLevel()];
}
// TZ 환경변수 기준 로컬 시간 (toISOString은 항상 UTC라 사용하지 않음)
export function timestamp(): string {
const d = new Date();
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const offMin = -d.getTimezoneOffset();
const sign = offMin >= 0 ? "+" : "-";
const abs = Math.abs(offMin);
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
`.${pad(d.getMilliseconds(), 3)}` +
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
);
}
function write(
level: Level,
ctx: string,
message: string,
data?: Record<string, unknown>
): void {
if (!shouldLog(level)) return;
const ts = timestamp();
const tag = level.toUpperCase().padEnd(5);
const line = data
? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}`
: `[${ts}] [${tag}] [${ctx}] ${message}`;
if (level === "warn" || level === "error") {
console.error(line);
} else {
console.log(line);
}
}
export const logger = {
debug: (ctx: string, message: string, data?: Record<string, unknown>) =>
write("debug", ctx, message, data),
info: (ctx: string, message: string, data?: Record<string, unknown>) =>
write("info", ctx, message, data),
warn: (ctx: string, message: string, data?: Record<string, unknown>) =>
write("warn", ctx, message, data),
error: (ctx: string, message: string, data?: Record<string, unknown>) =>
write("error", ctx, message, data),
isEnabled: (level: Level): boolean => shouldLog(level),
};
+711
View File
@@ -0,0 +1,711 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import { formatDate } from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const;
const sortOptions = [
"id-desc",
"id-asc",
"joinedAt-desc",
"joinedAt-asc",
"availableAt-desc",
"availableAt-asc",
] as const;
const maestroListSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
activateStatus: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(undefined),
accountType: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(undefined),
sort: z.enum(sortOptions).catch("id-desc"),
});
type PageSize = (typeof pageSizeOptions)[number];
export type MaestroSort = (typeof sortOptions)[number];
export type MaestroListSearchParams = z.infer<typeof maestroListSearchSchema>;
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type MaestroListItem = {
maestroID: number;
name: string;
email: string;
accountType: number;
activateStatus: number;
availableActivateDateTime: string;
acceptClausesDateTime: string;
playerCount: number;
};
export type MaestroListResult = {
items: MaestroListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
activateStatus?: number | "all";
accountType?: number | "all";
sort: MaestroSort;
};
export type MaestroDetail = {
maestro: MaestroListItem & {
allowEditEnterCode: number;
maestroTestID: number | null;
};
counts: {
players: number;
extensionRequests: number;
upgradeRequests: number;
};
players: Array<{
playerID: number;
name: string;
enterCode: string;
accountType: number | null;
}>;
extensionRequests: Array<{
maestroExtensionID: number;
accountType: number;
requestedDateTime: string;
status: number;
}>;
upgradeRequests: Array<{
maestroUpgradeID: number;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
requestedDateTime: string;
status: number;
}>;
};
const activateStatusValues = [
ACTIVATE_STATUSES.PENDING,
ACTIVATE_STATUSES.TRIAL,
ACTIVATE_STATUSES.ACTIVE,
ACTIVATE_STATUSES.CANCELED,
] as const;
export const updateMaestroSchema = z.object({
name: z.string().trim().min(1).max(50).optional(),
email: z.string().trim().email().max(50).optional(),
activateStatus: z.coerce
.number()
.int()
.refine((v) => (activateStatusValues as readonly number[]).includes(v))
.optional(),
accountType: z.coerce
.number()
.int()
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v))
.optional(),
availableActivateDateTime: z.string().min(1).optional(),
allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(),
});
export type UpdateMaestroPayload = z.infer<typeof updateMaestroSchema>;
export async function updateMaestro(
maestroID: number,
payload: UpdateMaestroPayload
): Promise<{ changed: boolean } | null> {
const t0 = Date.now();
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: {
Name: true,
Email: true,
ActivateStatus: true,
AccountType: true,
AvailableActivateDateTime: true,
AllowEditEnterCode: true,
},
});
if (!maestro) return null;
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
const conflict = await db.maestro.findFirst({
where: { Name: payload.name, NOT: { MaestroID: maestroID } },
select: { MaestroID: true },
});
if (conflict) {
throw new ApiError("이미 사용 중인 이름입니다.", 409);
}
}
const updates: {
Name?: string;
Email?: string;
ActivateStatus?: number;
AccountType?: number;
AvailableActivateDateTime?: Date;
AllowEditEnterCode?: number;
} = {};
const logs: Array<{ type: string; remark: string }> = [];
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
updates.Name = payload.name;
logs.push({
type: "update_maestro_name",
remark: `${maestro.Name.trim()} -> ${payload.name}`.slice(0, 100),
});
}
if (payload.email !== undefined && payload.email !== maestro.Email.trim()) {
updates.Email = payload.email;
logs.push({
type: "update_maestro_email",
remark: `${maestro.Email.trim()} -> ${payload.email}`.slice(0, 100),
});
}
if (
payload.activateStatus !== undefined &&
payload.activateStatus !== maestro.ActivateStatus
) {
updates.ActivateStatus = payload.activateStatus;
logs.push({
type: "update_maestro_status",
remark: `${maestro.ActivateStatus} -> ${payload.activateStatus}`,
});
}
if (
payload.accountType !== undefined &&
payload.accountType !== maestro.AccountType
) {
updates.AccountType = payload.accountType;
logs.push({
type: "update_maestro_account_type",
remark: `${maestro.AccountType} -> ${payload.accountType}`,
});
}
if (payload.availableActivateDateTime !== undefined) {
const newDate = new Date(payload.availableActivateDateTime);
if (isNaN(newDate.getTime())) {
throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
}
const prevMin = Math.floor(
maestro.AvailableActivateDateTime.getTime() / 60000
);
const newMin = Math.floor(newDate.getTime() / 60000);
if (prevMin !== newMin) {
updates.AvailableActivateDateTime = newDate;
const prevStr = formatDate(maestro.AvailableActivateDateTime);
const newStr = formatDate(newDate);
logs.push({
type: "update_maestro_available_date",
remark: `${prevStr} -> ${newStr}`,
});
}
}
if (
payload.allowEditEnterCode !== undefined &&
payload.allowEditEnterCode !== maestro.AllowEditEnterCode
) {
updates.AllowEditEnterCode = payload.allowEditEnterCode;
logs.push({
type: "update_maestro_allow_enter_code",
remark: `${maestro.AllowEditEnterCode} -> ${payload.allowEditEnterCode}`,
});
}
if (Object.keys(updates).length === 0) {
return { changed: false };
}
await db.$transaction([
db.maestro.update({ where: { MaestroID: maestroID }, data: updates }),
...logs.map((log) =>
db.maestro_log.create({
data: {
Type: log.type,
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: log.remark,
},
})
),
]);
logger.info("maestros/[id]", "updated", {
id: maestroID,
fields: Object.keys(updates),
duration: Date.now() - t0,
});
return { changed: true };
}
export async function resetMaestroPassword(
maestroID: number
): Promise<boolean> {
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestro) return false;
await db.$transaction(async (tx) => {
await tx.$executeRaw`UPDATE maestro SET Password = PASSWORD('123456') WHERE MaestroID = ${maestroID}`;
await tx.maestro_log.create({
data: {
Type: "reset_maestro_password",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: "admin reset to 123456",
},
});
});
return true;
}
const logPageSizeOptions = [10, 20, 50] as const;
type LogPageSize = (typeof logPageSizeOptions)[number];
const logSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((v) => logPageSizeOptions.includes(v as LogPageSize))
.catch(10),
});
export type MaestroLogsResult = {
items: Array<{
maestroLogID: number;
type: string;
logDateTime: string;
remark: string;
}>;
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
export async function getMaestroLogs(
maestroID: number,
rawSearchParams: RawSearchParams = {}
): Promise<MaestroLogsResult | null> {
const t0 = Date.now();
const maestroExists = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestroExists) return null;
const params = logSearchSchema.parse(searchParamsToRecord(rawSearchParams));
const skip = (params.page - 1) * params.pageSize;
const [totalCount, logs] = await Promise.all([
db.maestro_log.count({ where: { MaestroID: maestroID } }),
db.maestro_log.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroLogID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroLogID: true,
Type: true,
LogDateTime: true,
Remark: true,
},
}),
]);
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
logger.debug("maestros/[id]/logs", "fetched", {
id: maestroID,
page: params.page,
totalCount,
duration: Date.now() - t0,
});
return {
items: logs.map((log) => ({
maestroLogID: log.MaestroLogID,
type: log.Type.trim(),
logDateTime: log.LogDateTime.toISOString(),
remark: log.Remark.trim(),
})),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages,
};
}
const studentPageSizeOptions = [10, 20, 50] as const;
type StudentPageSize = (typeof studentPageSizeOptions)[number];
const studentSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((v) => studentPageSizeOptions.includes(v as StudentPageSize))
.catch(20),
q: z.string().trim().catch(""),
});
export type MaestroStudentsResult = {
items: Array<{
playerID: number;
name: string;
enterCode: string;
accountType: number | null;
}>;
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
};
export async function getMaestroStudents(
maestroID: number,
rawSearchParams: RawSearchParams = {}
): Promise<MaestroStudentsResult | null> {
const t0 = Date.now();
const maestroExists = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestroExists) return null;
const params = studentSearchSchema.parse(searchParamsToRecord(rawSearchParams));
const skip = (params.page - 1) * params.pageSize;
const where: Prisma.playerWhereInput = {
MaestroID: maestroID,
...(params.q ? { Name: { contains: params.q } } : {}),
};
const [totalCount, players] = await Promise.all([
db.player.count({ where }),
db.player.findMany({
where,
orderBy: { PlayerID: "desc" },
skip,
take: params.pageSize,
select: {
PlayerID: true,
Name: true,
EnterCode: true,
AccountType: true,
},
}),
]);
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
logger.debug("maestros/[id]/students", "fetched", {
id: maestroID,
q: params.q,
page: params.page,
totalCount,
duration: Date.now() - t0,
});
return {
items: players.map((p) => ({
playerID: p.PlayerID,
name: p.Name.trim(),
enterCode: p.EnterCode.trim(),
accountType: p.AccountType,
})),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages,
q: params.q,
};
}
export async function getMaestros(
rawSearchParams: RawSearchParams = {}
): Promise<MaestroListResult> {
const t0 = Date.now();
const params = parseMaestroListSearchParams(rawSearchParams);
const where = buildMaestroWhere(params);
const orderBy = buildMaestroOrderBy(params.sort);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, maestros] = await Promise.all([
db.maestro.count({ where }),
db.maestro.findMany({
where,
orderBy,
skip,
take: params.pageSize,
select: {
MaestroID: true,
Name: true,
Email: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
AcceptClausesDateTime: true,
PlayerCount: true,
},
}),
]);
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
logger.info("maestros", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return {
items: maestros.map(toMaestroListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages,
q: params.q,
activateStatus: params.activateStatus,
accountType: params.accountType,
sort: params.sort,
};
}
export async function getMaestroDetail(
maestroID: number
): Promise<MaestroDetail | null> {
const t0 = Date.now();
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: {
MaestroID: true,
Name: true,
Email: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
AcceptClausesDateTime: true,
PlayerCount: true,
AllowEditEnterCode: true,
MaestroTestID: true,
},
});
if (!maestro) {
logger.warn("maestros/[id]", "not found", { id: maestroID });
return null;
}
const [
playersCount,
extensionRequestsCount,
upgradeRequestsCount,
players,
extensionRequests,
upgradeRequests,
] = await Promise.all([
db.player.count({ where: { MaestroID: maestroID } }),
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
db.player.findMany({
where: { MaestroID: maestroID },
orderBy: { PlayerID: "desc" },
take: 20,
select: {
PlayerID: true,
Name: true,
EnterCode: true,
AccountType: true,
},
}),
db.maestro_extension.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroExtensionID: "desc" },
take: 20,
select: {
MaestroExtensionID: true,
AccountType: true,
RequestedDateTime: true,
Status: true,
},
}),
db.maestro_upgrade.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroUpgradeID: "desc" },
take: 20,
select: {
MaestroUpgradeID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
RequestedDateTime: true,
Status: true,
},
}),
]);
logger.info("maestros/[id]", "fetched", {
id: maestroID,
duration: Date.now() - t0,
});
return {
maestro: {
...toMaestroListItem(maestro),
allowEditEnterCode: maestro.AllowEditEnterCode,
maestroTestID: maestro.MaestroTestID,
},
counts: {
players: playersCount,
extensionRequests: extensionRequestsCount,
upgradeRequests: upgradeRequestsCount,
},
players: players.map((player) => ({
playerID: player.PlayerID,
name: player.Name.trim(),
enterCode: player.EnterCode.trim(),
accountType: player.AccountType,
})),
extensionRequests: extensionRequests.map((request) => ({
maestroExtensionID: request.MaestroExtensionID,
accountType: request.AccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
})),
upgradeRequests: upgradeRequests.map((request) => ({
maestroUpgradeID: request.MaestroUpgradeID,
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
})),
};
}
export function parseMaestroListSearchParams(
rawSearchParams: RawSearchParams
): MaestroListSearchParams {
return maestroListSearchSchema.parse(searchParamsToRecord(rawSearchParams));
}
function buildMaestroWhere(
params: MaestroListSearchParams
): Prisma.maestroWhereInput {
const conditions: Prisma.maestroWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestroWhereInput[] = [
{ Name: { contains: params.q } },
{ Email: { contains: params.q } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift({ MaestroID: numericQuery });
}
conditions.push({ OR: keywordConditions });
}
if (
typeof params.activateStatus === "number" &&
Number.isInteger(params.activateStatus)
) {
conditions.push({ ActivateStatus: params.activateStatus });
}
if (
typeof params.accountType === "number" &&
Number.isInteger(params.accountType)
) {
conditions.push({ AccountType: params.accountType });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function buildMaestroOrderBy(
sort: MaestroSort
): Prisma.maestroOrderByWithRelationInput {
switch (sort) {
case "id-asc":
return { MaestroID: "asc" };
case "joinedAt-desc":
return { AcceptClausesDateTime: "desc" };
case "joinedAt-asc":
return { AcceptClausesDateTime: "asc" };
case "availableAt-desc":
return { AvailableActivateDateTime: "desc" };
case "availableAt-asc":
return { AvailableActivateDateTime: "asc" };
case "id-desc":
default:
return { MaestroID: "desc" };
}
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toMaestroListItem(maestro: {
MaestroID: number;
Name: string;
Email: string;
AccountType: number;
ActivateStatus: number;
AvailableActivateDateTime: Date;
AcceptClausesDateTime: Date;
PlayerCount: number;
}): MaestroListItem {
return {
maestroID: maestro.MaestroID,
name: maestro.Name.trim(),
email: maestro.Email.trim(),
accountType: maestro.AccountType,
activateStatus: maestro.ActivateStatus,
availableActivateDateTime: maestro.AvailableActivateDateTime.toISOString(),
acceptClausesDateTime: maestro.AcceptClausesDateTime.toISOString(),
playerCount: maestro.PlayerCount,
};
}
+28
View File
@@ -0,0 +1,28 @@
export type MailConfig = {
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassword: string;
fromName: string;
fromAddress: string;
bccAddress: string;
sendEnabled: boolean;
/** 설정 시 모든 수신인을 이 주소로 고정 (local/stage 테스트용) */
overrideTo: string | undefined;
};
export function getMailConfig(): MailConfig {
return {
smtpHost: process.env.MAIL_SMTP_HOST ?? "smtp.gmail.com",
smtpPort: Number(process.env.MAIL_SMTP_PORT ?? "465"),
smtpSecure: process.env.MAIL_SMTP_SECURE !== "false",
smtpUser: process.env.MAIL_SMTP_USER ?? "",
smtpPassword: process.env.MAIL_SMTP_PASSWORD ?? "",
fromName: process.env.MAIL_FROM_NAME ?? "초코마에",
fromAddress: process.env.MAIL_FROM_ADDRESS ?? "",
bccAddress: process.env.MAIL_BCC_ADDRESS ?? "",
sendEnabled: process.env.MAIL_SEND_ENABLED === "true",
overrideTo: process.env.MAIL_OVERRIDE_TO || undefined,
};
}
+158
View File
@@ -0,0 +1,158 @@
import nodemailer from "nodemailer";
import { ACTIVATE_STATUSES } from "@/lib/constants";
import { logger } from "@/lib/logger";
import { getMailConfig } from "@/lib/mail-config";
import {
getAccountTypePlayerCount,
getAccountTypePrice,
} from "@/lib/utils";
const CTX = "mail";
function createTransporter() {
const cfg = getMailConfig();
return nodemailer.createTransport({
host: cfg.smtpHost,
port: cfg.smtpPort,
secure: cfg.smtpSecure,
auth: {
user: cfg.smtpUser,
pass: cfg.smtpPassword,
},
});
}
type SendMailOptions = {
to: string;
subject: string;
html: string;
};
const isDetailedLog = () => process.env.APP_ENV !== "production";
async function sendMail(options: SendMailOptions): Promise<boolean> {
const cfg = getMailConfig();
const detailed = isDetailedLog();
if (!cfg.sendEnabled) {
logger.info(CTX, "send skipped (MAIL_SEND_ENABLED=false)", {
to: cfg.overrideTo ?? options.to,
subject: options.subject,
});
return false;
}
const to = cfg.overrideTo ?? options.to;
const bcc = cfg.bccAddress || undefined;
if (detailed) {
logger.debug(CTX, "sending", {
to,
...(bcc && { bcc }),
subject: options.subject,
smtp: `${cfg.smtpUser}@${cfg.smtpHost}:${cfg.smtpPort}`,
...(cfg.overrideTo && { overrideTo: cfg.overrideTo }),
});
}
const t0 = Date.now();
try {
const transporter = createTransporter();
const info = await transporter.sendMail({
from: `"${cfg.fromName}" <${cfg.fromAddress}>`,
replyTo: cfg.fromAddress,
to,
bcc,
subject: options.subject,
html: options.html,
});
const duration = Date.now() - t0;
if (detailed) {
logger.info(CTX, "send ok", {
to,
subject: options.subject,
messageId: info.messageId,
duration,
});
} else {
logger.info(CTX, "send ok", { duration });
}
return true;
} catch (err) {
const duration = Date.now() - t0;
if (detailed) {
logger.error(CTX, "send failed", {
to,
subject: options.subject,
duration,
error: err instanceof Error ? err.message : String(err),
...(err instanceof Error && err.stack && { stack: err.stack }),
});
} else {
logger.error(CTX, "send failed", { duration });
}
return false;
}
}
export async function sendExtensionDoneEmail(
maestroName: string,
maestroEmail: string,
newAvailableDate: string
): Promise<boolean> {
const subject = `[초코마에] ${maestroName} - 마에스트로 계정 유효기간 연장 완료`;
const html = `안녕하세요.<br/>
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
<br/>
${maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
* 새로운 유효기간 : ${newAvailableDate}<br/>
<br/>
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
<br/>
감사합니다.`;
return sendMail({ to: maestroEmail, subject, html });
}
export async function sendUpgradeDoneEmail(
maestroName: string,
maestroEmail: string,
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number,
newAvailableDate: string
): Promise<boolean> {
const subject = `[초코마에] ${maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`;
const prevStudentCount = getAccountTypePlayerCount(registeredAccountType);
const upgradeStudentCount = getAccountTypePlayerCount(requestedAccountType);
const upgradePrice = getAccountTypePrice(requestedAccountType).toLocaleString("ko-KR");
let upgradeFromText: string;
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
upgradeFromText = `${prevStudentCount}명 (0원 : 무료 체험 기간)`;
} else {
const prevPrice = getAccountTypePrice(registeredAccountType).toLocaleString("ko-KR");
upgradeFromText = `${prevStudentCount}명 (${prevPrice}원)`;
}
const html = `안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
${maestroName} - 마에스트로 계정의 요금제가 [ ${upgradeFromText} ] 에서 [ ${upgradeStudentCount}명 (${upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.`;
return sendMail({ to: maestroEmail, subject, html });
}
+431
View File
@@ -0,0 +1,431 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
calculateUpgradePrice,
formatDate,
getAccountTypeLabel,
getAccountTypePrice,
getTrialAccountTypeLabel,
} from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const;
const upgradeRequestSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED),
});
type PageSize = (typeof pageSizeOptions)[number];
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type UpgradeRequestSearchParams = z.infer<
typeof upgradeRequestSearchSchema
>;
export type UpgradeRequestListItem = {
maestroUpgradeID: number;
maestroID: number;
maestroName: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
requestedDateTime: string;
availableActivateDateTime: string;
status: number;
additionalPrice: number;
};
export type UpgradeRequestListResult = {
items: UpgradeRequestListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
status: number | "all";
};
export async function getUpgradeRequests(
rawSearchParams: RawSearchParams = {}
): Promise<UpgradeRequestListResult> {
const t0 = Date.now();
const params = parseUpgradeRequestSearchParams(rawSearchParams);
const where = buildUpgradeRequestWhere(params);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, requests] = await Promise.all([
db.maestro_upgrade.count({ where }),
db.maestro_upgrade.findMany({
where,
orderBy: { MaestroUpgradeID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroUpgradeID: true,
MaestroID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
RequestedDateTime: true,
Status: true,
maestro: {
select: {
Name: true,
AvailableActivateDateTime: true,
},
},
},
}),
]);
logger.info("upgrade-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return {
items: requests.map(toUpgradeRequestListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
q: params.q,
status: params.status,
};
}
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}> {
return db.$transaction(async (tx) => {
// Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RequestedAccountType: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
maestro: { select: { Name: true, Email: true, AccountType: true, ActivateStatus: true } },
},
});
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
const isStillValidUpgrade = isApprovalTrial
? request!.RequestedAccountType >= request!.maestro.AccountType
: request!.RequestedAccountType > request!.maestro.AccountType;
if (!isStillValidUpgrade) {
throw new ApiError(
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
409
);
}
const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({
where: { MaestroID: request!.MaestroID },
data: {
AccountType: request!.RequestedAccountType,
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime,
},
});
await tx.maestro_upgrade.updateMany({
where: {
MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED,
},
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_log.create({
data: {
Type: "upgrade_maestro",
MaestroID: request!.MaestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
request!.RegisteredActivateStatus,
request!.RegisteredAccountType,
request!.RequestedAccountType
),
},
});
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request!.maestro.Name.trim(),
maestroEmail: request!.maestro.Email.trim(),
registeredActivateStatus: request!.RegisteredActivateStatus,
registeredAccountType: request!.RegisteredAccountType,
requestedAccountType: request!.RequestedAccountType,
};
});
}
export async function createUpgradeRequest(
maestroID: number,
requestedAccountType: number
): Promise<{ maestroUpgradeID: number; registeredAccountType: number; requestedDateTime: string; status: number }> {
return db.$transaction(async (tx) => {
const maestro = await tx.maestro.findUnique({
where: { MaestroID: maestroID },
select: { ActivateStatus: true, AccountType: true },
});
if (!maestro) {
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
}
const isTrial = maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
const isValidUpgrade = isTrial
? requestedAccountType >= maestro.AccountType
: requestedAccountType > maestro.AccountType;
if (!isValidUpgrade) {
throw new ApiError(
isTrial
? "체험 계정은 동일 요금제 이상으로만 업그레이드 신청이 가능합니다."
: "현재 요금제보다 높은 요금제로만 업그레이드 신청이 가능합니다.",
400
);
}
const request = await tx.maestro_upgrade.create({
data: {
MaestroID: maestroID,
RegisteredActivateStatus: maestro.ActivateStatus,
RegisteredAccountType: maestro.AccountType,
RequestedAccountType: requestedAccountType,
RequestedDateTime: new Date(),
Status: REQUEST_STATUSES.REQUESTED,
},
select: {
MaestroUpgradeID: true,
RegisteredAccountType: true,
RequestedDateTime: true,
Status: true,
},
});
await tx.maestro_log.create({
data: {
Type: "request_upgrade_maestro",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
maestro.ActivateStatus,
maestro.AccountType,
requestedAccountType
),
},
});
return {
maestroUpgradeID: request.MaestroUpgradeID,
registeredAccountType: request.RegisteredAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
};
});
}
export async function rejectUpgradeRequest(
maestroUpgradeID: number
): Promise<void> {
await db.$transaction(async (tx) => {
const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.CLOSED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
},
});
await tx.maestro_log.create({
data: {
Type: "reject_upgrade_maestro",
MaestroID: request!.MaestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
request!.RegisteredActivateStatus,
request!.RegisteredAccountType,
request!.RequestedAccountType
),
},
});
});
}
export function parseUpgradeRequestSearchParams(
rawSearchParams: RawSearchParams
): UpgradeRequestSearchParams {
return upgradeRequestSearchSchema.parse(
searchParamsToRecord(rawSearchParams)
);
}
function buildUpgradeRequestWhere(
params: UpgradeRequestSearchParams
): Prisma.maestro_upgradeWhereInput {
const conditions: Prisma.maestro_upgradeWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestro_upgradeWhereInput[] = [
{ maestro: { Name: { contains: params.q } } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift(
{ MaestroUpgradeID: numericQuery },
{ MaestroID: numericQuery }
);
}
conditions.push({ OR: keywordConditions });
}
if (typeof params.status === "number" && Number.isInteger(params.status)) {
conditions.push({ Status: params.status });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toUpgradeRequestListItem(request: {
MaestroUpgradeID: number;
MaestroID: number;
RegisteredActivateStatus: number;
RegisteredAccountType: number;
RequestedAccountType: number;
RequestedDateTime: Date;
Status: number;
maestro: {
Name: string;
AvailableActivateDateTime: Date;
};
}): UpgradeRequestListItem {
return {
maestroUpgradeID: request.MaestroUpgradeID,
maestroID: request.MaestroID,
maestroName: request.maestro.Name.trim(),
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
requestedDateTime: formatDate(request.RequestedDateTime),
availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status,
additionalPrice: calculateAdditionalPrice(
request.RegisteredActivateStatus,
request.RegisteredAccountType,
request.RequestedAccountType
),
};
}
function calculateAdditionalPrice(
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number
) {
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
return getAccountTypePrice(requestedAccountType);
}
return calculateUpgradePrice(requestedAccountType, registeredAccountType);
}
function calculateUpgradeAvailableDate(now = new Date()) {
const availableDate = new Date(now);
availableDate.setFullYear(availableDate.getFullYear() + 1);
return availableDate;
}
function buildUpgradeLogRemark(
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number
) {
const registeredLabel =
registeredActivateStatus === ACTIVATE_STATUSES.TRIAL
? getTrialAccountTypeLabel(registeredAccountType)
: getAccountTypeLabel(registeredAccountType);
return `${registeredLabel} -> ${getAccountTypeLabel(requestedAccountType)}`.slice(
0,
100
);
}
+135
View File
@@ -0,0 +1,135 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import {
ACCOUNT_TYPE_LABELS,
ACCOUNT_TYPE_PLAYER_COUNTS,
ACCOUNT_TYPE_PRICES,
ACTIVATE_STATUS_LABELS,
REQUEST_STATUS_LABELS,
TRIAL_ACCOUNT_TYPE_LABELS,
type AccountType,
type ActivateStatus,
type PaidAccountType,
type RequestStatus,
} from "@/lib/constants";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getAccountTypeLabel(accountType: number): string {
return (
ACCOUNT_TYPE_LABELS[accountType as AccountType] ?? `${accountType}? (N/A)`
);
}
export function getTrialAccountTypeLabel(accountType: number): string {
return (
TRIAL_ACCOUNT_TYPE_LABELS[accountType as PaidAccountType] ??
`${accountType}? (N/A)`
);
}
export function getAccountTypePlayerCount(accountType: number): number {
return ACCOUNT_TYPE_PLAYER_COUNTS[accountType as PaidAccountType] ?? 0;
}
export function getAccountTypePrice(accountType: number): number {
return ACCOUNT_TYPE_PRICES[accountType as PaidAccountType] ?? 0;
}
export function getActivateStatusLabel(activateStatus: number): string {
return (
ACTIVATE_STATUS_LABELS[activateStatus as ActivateStatus] ??
`${activateStatus}? (N/A)`
);
}
export function getRequestStatusLabel(status: number): string {
return REQUEST_STATUS_LABELS[status as RequestStatus] ?? `${status}? (N/A)`;
}
export function calculateUpgradePrice(
requestedAccountType: number,
registeredAccountType: number
): number {
return (
getAccountTypePrice(requestedAccountType) -
getAccountTypePrice(registeredAccountType)
);
}
export function formatDate(value: Date | string | null | undefined): string {
const date = toDate(value);
if (!date) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDateTime(
value: Date | string | null | undefined
): string {
const date = toDate(value);
if (!date) {
return "";
}
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${formatDate(date)} ${hours}:${minutes}:${seconds}`;
}
export function calculateExtendedAvailableDate(
availableDate: Date,
now = new Date()
): Date {
const baseDate =
availableDate.getTime() < now.getTime() ? now : availableDate;
const extendedDate = new Date(baseDate);
extendedDate.setFullYear(extendedDate.getFullYear() + 1);
extendedDate.setHours(0, 0, 0, 0);
return extendedDate;
}
export function toDatetimeLocal(value: Date | string | null | undefined): string {
const date = toDate(value);
if (!date) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function toDate(value: Date | string | null | undefined): Date | null {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+50
View File
@@ -0,0 +1,50 @@
{
"name": "chocoadmin",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.29.3",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:check": "tsx scripts/check-db.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"mariadb": "^3.5.2",
"next": "16.2.7",
"next-auth": "5.0.0-beta.31",
"nodemailer": "^9.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.11.0",
"sql-formatter": "^15.8.2",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"prettier": "^3.8.4",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
}
}
+7083
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
import { config } from "dotenv";
import { defineConfig } from "prisma/config";
config({ path: ".env.local" });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+254
View File
@@ -0,0 +1,254 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "mysql"
}
model active_app {
ActiveAppID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "active_app_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "active_app_ibfk_2")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
}
model admin {
AdminID Int @id @default(autoincrement()) @db.UnsignedInt
Name String @db.Char(50)
Password String @db.Char(50)
Email String @db.Char(50)
AccountType Int @db.UnsignedInt
ActivateStatus Int @db.UnsignedInt
}
model ads_client {
AdsClientID Int @id @default(autoincrement()) @db.UnsignedInt
ClientName String @db.Char(50)
typing_exam_ads typing_exam_ads[]
}
model app {
AppID Int @id @default(autoincrement()) @db.UnsignedInt
AppName String @db.Char(40)
KoreanName String @db.Char(40)
AppType Int @db.UnsignedInt
Status Int @db.UnsignedInt
HowToPlay String @db.Text
active_app active_app[]
app_highest_record app_highest_record[]
best_record best_record[]
}
model app_highest_record {
AppHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
HighestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "app_highest_record_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "app_highest_record_ibfk_2")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "app_highest_record_ibfk_3")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model banned_word {
BannedWordID Int @id @default(autoincrement()) @db.UnsignedInt
Word String @db.Char(50)
}
model best_record {
BestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
BestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "best_record_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "best_record_ibfk_2")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "best_record_ibfk_3")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model license_maestro_password {
LicenseMaestroPasswordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
Password Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_maestro_password_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model license_score {
LicenseScoreID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
SubjectName String @db.Char(50)
Score Int @db.UnsignedInt
ScoreDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_score_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_score_ibfk_2")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model license_time {
LicenseTimeID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
StartTime Int @db.UnsignedInt
LeftTime Int
SavedDateTime DateTime @db.DateTime(0)
SubjectName String @db.Char(50)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_time_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_time_ibfk_2")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro {
MaestroID Int @id @default(autoincrement()) @db.UnsignedInt
Name String @db.Char(50)
Password String @db.Char(50)
Email String @db.Char(50)
AccountType Int @db.UnsignedInt
ActivateStatus Int @db.UnsignedInt
AvailableActivateDateTime DateTime @db.DateTime(0)
PlayerCount Int @db.UnsignedInt
AcceptClausesDateTime DateTime @db.DateTime(0)
AllowEditEnterCode Int @db.UnsignedInt
MaestroTestID Int? @db.UnsignedInt
active_app active_app[]
app_highest_record app_highest_record[]
best_record best_record[]
license_maestro_password license_maestro_password[]
license_score license_score[]
license_time license_time[]
maestro_extension maestro_extension[]
maestro_upgrade maestro_upgrade[]
player player[]
typing_exam_record typing_exam_record[]
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro_extension {
MaestroExtensionID Int @id @default(autoincrement()) @db.UnsignedInt
AccountType Int @db.UnsignedInt
RequestedDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
MaestroID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_extension_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model maestro_log {
MaestroLogID Int @id @default(autoincrement()) @db.UnsignedInt
Type String @db.Char(50)
MaestroID Int @db.UnsignedInt
LogDateTime DateTime @db.DateTime(0)
Remark String @db.Char(100)
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro_upgrade {
MaestroUpgradeID Int @id @default(autoincrement()) @db.UnsignedInt
RegisteredActivateStatus Int @default(1) @db.UnsignedInt
RegisteredAccountType Int
RequestedAccountType Int @db.UnsignedInt
RequestedDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
MaestroID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_upgrade_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model player {
PlayerID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
Name String @db.Char(50)
EnterCode String @db.Char(8)
AccountType Int? @default(0) @db.UnsignedInt
app_highest_record app_highest_record[]
best_record best_record[]
license_score license_score[]
license_time license_time[]
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "player_ibfk_1")
typing_exam_record typing_exam_record[]
@@index([MaestroID], map: "MaestroID")
}
model typing_exam_ads {
TypingExamAdsID Int @id @default(autoincrement()) @db.UnsignedInt
AdsClientID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
FromDateTime DateTime @db.DateTime(0)
ToDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
Cost Float @db.Float
ads_client ads_client @relation(fields: [AdsClientID], references: [AdsClientID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_1")
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_2")
@@index([AdsClientID], map: "AdsClientID")
@@index([WritingID], map: "WritingID")
}
model typing_exam_highest_record {
TypingExamHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
HighestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
@@index([WritingID], map: "WritingID")
}
model typing_exam_record {
TypingExamRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
Record Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "typing_exam_record_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "typing_exam_record_ibfk_2")
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_record_ibfk_3")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
@@index([WritingID], map: "WritingID")
}
model writing {
WritingID Int @id @default(autoincrement()) @db.UnsignedInt
ActivateStatus Int
Language String @db.Char(10)
Name String @db.Char(50)
Filename String @db.Char(50)
Writer String @db.Char(50)
WriterID Int? @db.UnsignedInt
UpdateDate DateTime @db.DateTime(0)
LetterCount Int @db.UnsignedInt
typing_exam_ads typing_exam_ads[]
typing_exam_record typing_exam_record[]
}
+39
View File
@@ -0,0 +1,39 @@
import { getToken } from "next-auth/jwt";
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC_PATHS = ["/login"];
function usesSecureAuthCookies() {
const authUrl = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL;
return authUrl?.startsWith("https://") ?? false;
}
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isPublicPath = PUBLIC_PATHS.includes(pathname);
const cookieName = `chocoadmin-${process.env.APP_ENV ?? "local"}.session-token`;
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
secureCookie: usesSecureAuthCookies(),
cookieName,
});
if (!token && !isPublicPath) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
if (token && isPublicPath) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+87
View File
@@ -0,0 +1,87 @@
import { config } from "dotenv";
config({ path: ".env.local" });
async function main() {
const { db } = await import("@/lib/db");
const { getAccountTypeLabel, getActivateStatusLabel, getRequestStatusLabel } =
await import("@/lib/utils");
const [maestroCount, extensionCount, upgradeCount] = await Promise.all([
db.maestro.count(),
db.maestro_extension.count(),
db.maestro_upgrade.count(),
]);
const [maestros, extensionRequests, upgradeRequests] = await Promise.all([
db.maestro.findMany({
orderBy: { MaestroID: "desc" },
select: {
MaestroID: true,
Name: true,
AccountType: true,
ActivateStatus: true,
PlayerCount: true,
},
take: 3,
}),
db.maestro_extension.findMany({
orderBy: { MaestroExtensionID: "desc" },
select: {
MaestroExtensionID: true,
MaestroID: true,
AccountType: true,
Status: true,
},
take: 3,
}),
db.maestro_upgrade.findMany({
orderBy: { MaestroUpgradeID: "desc" },
select: {
MaestroUpgradeID: true,
MaestroID: true,
RegisteredAccountType: true,
RequestedAccountType: true,
Status: true,
},
take: 3,
}),
]);
console.log("DB connection OK");
console.log({
counts: {
maestro: maestroCount,
maestro_extension: extensionCount,
maestro_upgrade: upgradeCount,
},
maestros: maestros.map((maestro) => ({
id: maestro.MaestroID,
name: maestro.Name,
accountType: getAccountTypeLabel(maestro.AccountType),
activateStatus: getActivateStatusLabel(maestro.ActivateStatus),
playerCount: maestro.PlayerCount,
})),
extensionRequests: extensionRequests.map((request) => ({
id: request.MaestroExtensionID,
maestroId: request.MaestroID,
accountType: getAccountTypeLabel(request.AccountType),
status: getRequestStatusLabel(request.Status),
})),
upgradeRequests: upgradeRequests.map((request) => ({
id: request.MaestroUpgradeID,
maestroId: request.MaestroID,
from: getAccountTypeLabel(request.RegisteredAccountType),
to: getAccountTypeLabel(request.RequestedAccountType),
status: getRequestStatusLabel(request.Status),
})),
});
await db.$disconnect();
}
main().catch((error) => {
console.error("DB connection check failed");
console.error(error);
process.exitCode = 1;
});
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
DEPLOY_DIR="/volume1/docker/service/jinaju/chocoadmin"
SERVICE="chocoadmin"
URL="https://chocoadmin.jinaju.com"
echo -e "${RED}==============================${NC}"
echo -e "${RED} chocoadmin 운영 배포${NC}"
echo -e "${RED}==============================${NC}"
echo ""
echo -e "${RED}운영 서버에 배포합니다. 계속하시겠습니까? (yes 입력)${NC}"
read -r CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "배포를 취소했습니다."
exit 0
fi
echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}"
cd "$DEPLOY_DIR"
echo " $(pwd)"
echo -e "\n${YELLOW}[2/5] git pull${NC}"
git pull --ff-only
echo -e "\n${YELLOW}[3/5] proxy-network 확인${NC}"
if docker network inspect proxy-network >/dev/null 2>&1; then
echo " proxy-network 존재"
else
docker network create proxy-network
echo " proxy-network 생성 완료"
fi
echo -e "\n${YELLOW}[4/5] 빌드 및 컨테이너 시작${NC}"
docker compose up -d --build --remove-orphans
echo -e "\n${YELLOW}[5/5] NPM nginx 리로드${NC}"
docker exec npm nginx -s reload
echo " 리로드 완료"
echo -e "\n${YELLOW}컨테이너 상태${NC}"
docker compose ps
echo -e "\n${GREEN}배포 완료${NC}"
echo " $URL"
echo ""
echo "로그: docker compose -f $DEPLOY_DIR/docker-compose.yml logs -f $SERVICE"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
DEPLOY_DIR="/volume1/docker/service/jisangs/chocoadmin-stage"
COMPOSE_FILE="docker-compose.stage.yml"
SERVICE="chocoadmin-stage"
URL="https://chocoadmin-stage.jisangs.com"
echo -e "${BLUE}==============================${NC}"
echo -e "${BLUE} chocoadmin-stage 배포${NC}"
echo -e "${BLUE}==============================${NC}"
echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}"
cd "$DEPLOY_DIR"
echo " $(pwd)"
echo -e "\n${YELLOW}[2/5] git pull${NC}"
git pull --ff-only
echo -e "\n${YELLOW}[3/5] proxy-network 확인${NC}"
if docker network inspect proxy-network >/dev/null 2>&1; then
echo " proxy-network 존재"
else
docker network create proxy-network
echo " proxy-network 생성 완료"
fi
echo -e "\n${YELLOW}[4/5] 빌드 및 컨테이너 시작${NC}"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
echo -e "\n${YELLOW}[5/5] NPM nginx 리로드${NC}"
docker exec npm nginx -s reload
echo " 리로드 완료"
echo -e "\n${YELLOW}컨테이너 상태${NC}"
docker compose -f "$COMPOSE_FILE" ps
echo -e "\n${GREEN}배포 완료${NC}"
echo " $URL"
echo ""
echo "로그: docker compose -f $DEPLOY_DIR/$COMPOSE_FILE logs -f $SERVICE"
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
export type {
admin,
maestro,
maestro_extension,
maestro_log,
maestro_upgrade,
player,
} from "@/lib/generated/prisma/client";
export type {
AccountType,
ActivateStatus,
PaidAccountType,
RequestStatus,
} from "@/lib/constants";