Compare commits

...

46 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
60 changed files with 5375 additions and 649 deletions
+38
View File
@@ -4,6 +4,20 @@ AUTH_SECRET="replace-with-local-secret"
NEXTAUTH_SECRET="replace-with-local-secret" NEXTAUTH_SECRET="replace-with-local-secret"
AUTH_URL="http://localhost:3000" AUTH_URL="http://localhost:3000"
NEXTAUTH_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) # Production example (.env.production)
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae" # DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
@@ -11,6 +25,18 @@ NEXTAUTH_URL="http://localhost:3000"
# NEXTAUTH_SECRET="replace-with-production-secret" # NEXTAUTH_SECRET="replace-with-production-secret"
# AUTH_URL="https://NAS_HOST_OR_DOMAIN" # AUTH_URL="https://NAS_HOST_OR_DOMAIN"
# NEXTAUTH_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) # Stage example (.env.stage)
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae" # DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
@@ -18,3 +44,15 @@ NEXTAUTH_URL="http://localhost:3000"
# NEXTAUTH_SECRET="replace-with-stage-secret" # NEXTAUTH_SECRET="replace-with-stage-secret"
# AUTH_URL="https://STAGE_HOST_OR_DOMAIN" # AUTH_URL="https://STAGE_HOST_OR_DOMAIN"
# NEXTAUTH_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
+124 -2
View File
@@ -1,5 +1,127 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know # 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. 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.
<!-- END:nextjs-agent-rules -->
---
# 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
+147 -21
View File
@@ -1,36 +1,162 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # chocoadmin
## Getting Started chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스로 분리한 웹 애플리케이션이다.
First, run the development server: ## 기술 스택
| 구분 | 기술 |
|---|---|
| 프레임워크 | 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 ```bash
npm run dev pnpm install
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### 2. 환경변수 설정
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```bash
cp .env.example .env.local
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. `.env.local`을 열어 실제 값으로 교체한다.
## Learn More ```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"
```
To learn more about Next.js, take a look at the following resources: ### 3. DB 연결 확인
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. pnpm db:check
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ### 4. 개발 서버 실행
## Deploy on Vercel ```bash
pnpm dev
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. [http://localhost:3000](http://localhost:3000) 에서 확인한다.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## 환경변수 전체 목록
| 변수 | 필수 | 설명 |
|---|---|---|
| `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");
}
+2 -2
View File
@@ -118,7 +118,7 @@ export default async function ExtensionRequestsPage({
size: "default", size: "default",
className: "h-9", className: "h-9",
})} })}
href="/extension-requests" href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
> >
</Link> </Link>
@@ -133,7 +133,7 @@ export default async function ExtensionRequestsPage({
</p> </p>
<nav <nav
aria-label="연장 신청 목록 페이지" 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"
> >
{!hasFirstPage ? ( {!hasFirstPage ? (
<Link <Link
+9 -23
View File
@@ -1,22 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import { LogOut } from "lucide-react";
ArrowUpCircle,
ClipboardList,
GraduationCap,
LayoutDashboard,
LogOut,
} from "lucide-react";
import { auth, signOut } from "@/auth"; import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const navItems = [ import { logoutAction } from "./actions";
{ href: "/", label: "대시보드", icon: LayoutDashboard }, import { MobileNav } from "./MobileNav";
{ href: "/maestros", label: "마에스트로", icon: GraduationCap }, import { navItems } from "./nav-config";
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
];
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@@ -56,20 +47,15 @@ export default async function AdminLayout({
</aside> </aside>
<div className="md:pl-64"> <div className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5"> <header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-b bg-background px-5">
<div> <MobileNav />
<div className="flex-1">
<p className="text-sm font-medium">{session.user.name}</p> <p className="text-sm font-medium">{session.user.name}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
ID {session.user.adminID} ID {session.user.adminID}
</p> </p>
</div> </div>
<form <form action={logoutAction}>
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
>
<Button size="sm" type="submit" variant="outline"> <Button size="sm" type="submit" variant="outline">
<LogOut className="size-4" aria-hidden="true" /> <LogOut className="size-4" aria-hidden="true" />
@@ -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>
);
}
+24 -173
View File
@@ -6,11 +6,14 @@ import { buttonVariants } from "@/components/ui/button";
import { getMaestroDetail } from "@/lib/maestros"; import { getMaestroDetail } from "@/lib/maestros";
import { import {
formatDate, formatDate,
formatDateTime,
getAccountTypeLabel, getAccountTypeLabel,
getActivateStatusLabel, getActivateStatusLabel,
getRequestStatusLabel,
} from "@/lib/utils"; } 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 = { type MaestroDetailPageProps = {
params: Promise<{ params: Promise<{
@@ -81,119 +84,32 @@ export default async function MaestroDetailPage({
<section className="rounded-lg border bg-background p-5"> <section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2> <h2 className="text-base font-semibold"> </h2>
<dl className="mt-4 grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3"> <MaestroEditForm
<InfoItem label="이름" value={maestro.name} /> maestro={detail.maestro}
<InfoItem label="이메일" value={maestro.email} /> playerCount={detail.counts.players}
<InfoItem
label="상태"
value={getActivateStatusLabel(maestro.activateStatus)}
/>
<InfoItem
label="계정 유형"
value={getAccountTypeLabel(maestro.accountType)}
/>
<InfoItem
label="DB 학생 수"
value={detail.counts.players.toLocaleString()}
/>
<InfoItem
label="PlayerCount"
value={maestro.playerCount.toLocaleString()}
/>
<InfoItem
label="사용 가능일"
value={formatDateTime(maestro.availableActivateDateTime)}
/>
<InfoItem
label="약관 동의일"
value={formatDateTime(maestro.acceptClausesDateTime)}
/>
<InfoItem
label="입장코드 수정"
value={maestro.allowEditEnterCode === 1 ? "허용" : "비허용"}
/>
<InfoItem
label="체험 MaestroID"
value={maestro.maestroTestID?.toString() ?? "-"}
/>
</dl>
</section>
<section className="rounded-lg border bg-background p-5">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.players.toLocaleString()} 20
</p>
</div>
</div>
<SimpleTable
emptyText="등록된 학생이 없습니다."
headers={["PlayerID", "이름", "입장코드", "AccountType"]}
rows={detail.players.map((player) => [
player.playerID,
player.name,
player.enterCode,
player.accountType ?? "-",
])}
/> />
</section> </section>
<StudentsSection maestroID={maestroID} />
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border bg-background p-5"> <ExtensionRequestsSection
<h2 className="text-base font-semibold"> </h2> accountType={maestro.accountType}
<p className="mt-1 text-sm text-muted-foreground"> extensionRequests={detail.extensionRequests}
{detail.counts.extensionRequests.toLocaleString()} maestroID={maestroID}
20 totalCount={detail.counts.extensionRequests}
</p> />
<SimpleTable
emptyText="연장 신청 이력이 없습니다."
headers={["신청ID", "계정 유형", "신청일시", "상태"]}
rows={detail.extensionRequests.map((request) => [
request.maestroExtensionID,
getAccountTypeLabel(request.accountType),
formatDateTime(request.requestedDateTime),
getRequestStatusLabel(request.status),
])}
/>
</section>
<section className="rounded-lg border bg-background p-5"> <UpgradeRequestsSection
<h2 className="text-base font-semibold"> </h2> accountType={maestro.accountType}
<p className="mt-1 text-sm text-muted-foreground"> activateStatus={maestro.activateStatus}
{detail.counts.upgradeRequests.toLocaleString()} 20 maestroID={maestroID}
</p> totalCount={detail.counts.upgradeRequests}
<SimpleTable upgradeRequests={detail.upgradeRequests}
emptyText="업그레이드 신청 이력이 없습니다." />
headers={["신청ID", "현재", "요청", "신청일시", "상태"]}
rows={detail.upgradeRequests.map((request) => [
request.maestroUpgradeID,
getAccountTypeLabel(request.registeredAccountType),
getAccountTypeLabel(request.requestedAccountType),
formatDateTime(request.requestedDateTime),
getRequestStatusLabel(request.status),
])}
/>
</section>
</div> </div>
<section className="rounded-lg border bg-background p-5"> <LogsSection maestroID={maestroID} />
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.logs.toLocaleString()} 30
</p>
<SimpleTable
emptyText="처리 로그가 없습니다."
headers={["LogID", "유형", "처리일시", "내용"]}
rows={detail.logs.map((log) => [
log.maestroLogID,
log.type,
formatDateTime(log.logDateTime),
log.remark,
])}
/>
</section>
</section> </section>
); );
} }
@@ -206,68 +122,3 @@ function SummaryCard({ label, value }: { label: string; value: string }) {
</article> </article>
); );
} }
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 break-words font-medium">{value}</dd>
</div>
);
}
function SimpleTable({
headers,
rows,
emptyText,
}: {
headers: string[];
rows: Array<Array<string | number>>;
emptyText: string;
}) {
return (
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
{headers.map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
rows.map((row, index) => (
<tr className="border-b last:border-b-0" key={index}>
{row.map((cell, cellIndex) => (
<td
className="h-11 max-w-[360px] break-words px-3 align-middle"
key={cellIndex}
>
{cell}
</td>
))}
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={headers.length}
>
{emptyText}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+23 -22
View File
@@ -73,7 +73,7 @@ export default async function MaestrosPage({
</div> </div>
<form <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_110px_auto]" 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" method="get"
> >
<label className="min-w-0 space-y-1.5"> <label className="min-w-0 space-y-1.5">
@@ -148,23 +148,6 @@ export default async function MaestrosPage({
</AutoSubmitSelect> </AutoSubmitSelect>
</label> </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"> <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 className="h-9" type="submit">
@@ -185,12 +168,30 @@ export default async function MaestrosPage({
<MaestrosTable data={result.items} /> <MaestrosTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground"> <form className="flex items-center gap-2" method="get">
{result.pageSize} {Object.entries(params).flatMap(([key, value]) => {
</p> 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 <nav
aria-label="마에스트로 목록 페이지" 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"
> >
{!hasFirstPage ? ( {!hasFirstPage ? (
<Link <Link
+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 },
];
+2 -2
View File
@@ -120,7 +120,7 @@ export default async function UpgradeRequestsPage({
size: "default", size: "default",
className: "h-9", className: "h-9",
})} })}
href="/upgrade-requests" href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
> >
</Link> </Link>
@@ -135,7 +135,7 @@ export default async function UpgradeRequestsPage({
</p> </p>
<nav <nav
aria-label="업그레이드 신청 목록 페이지" 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"
> >
{!hasFirstPage ? ( {!hasFirstPage ? (
<Link <Link
+23 -2
View File
@@ -4,16 +4,35 @@ import { AuthError } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { signIn } from "@/auth"; 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) { export async function loginAction(formData: FormData) {
const callbackUrl = String(formData.get("callbackUrl") ?? "/"); const callbackUrl = sanitizeCallbackUrl(
String(formData.get("callbackUrl") ?? "/")
);
try { try {
await signIn("credentials", { const nextAuthRedirectUrl = await signIn("credentials", {
adminName: formData.get("adminName"), adminName: formData.get("adminName"),
password: formData.get("password"), password: formData.get("password"),
redirect: false,
redirectTo: callbackUrl, redirectTo: callbackUrl,
}); });
logger.info("auth", "login redirect", {
nextAuthRedirectUrl: String(nextAuthRedirectUrl),
callbackUrl,
});
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
redirect( redirect(
@@ -23,4 +42,6 @@ export async function loginAction(formData: FormData) {
throw error; throw error;
} }
redirect(callbackUrl);
} }
+35 -44
View File
@@ -1,67 +1,58 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/auth";
import { import {
approveExtensionRequest, approveExtensionRequest,
cancelExtensionRequest, cancelExtensionRequest,
ExtensionRequestActionError,
} from "@/lib/extension-requests"; } 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({ const patchBodySchema = z.object({
action: z.enum(["approve", "cancel"]), action: z.enum(["approve", "cancel"]),
}); });
type ExtensionRequestRouteContext = { export const PATCH = withApiHandler<{ id: string }>(
params: Promise<{ CTX,
id: string; async (request, { params, t0 }) => {
}>; const { id } = await params;
}; const maestroExtensionID = Number(id);
export async function PATCH( if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
request: Request, throw new ApiError("Invalid extension request id", 400);
{ params }: ExtensionRequestRouteContext }
) {
const session = await auth();
if (!session) { const body = patchBodySchema.safeParse(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); await request.json().catch(() => ({}))
}
const { id } = await params;
const maestroExtensionID = Number(id);
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
return NextResponse.json(
{ message: "Invalid extension request id" },
{ status: 400 }
); );
} if (!body.success) {
throw new ApiError("Invalid action", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
}
try {
if (body.data.action === "approve") { if (body.data.action === "approve") {
const result = await approveExtensionRequest(maestroExtensionID); const result = await approveExtensionRequest(maestroExtensionID);
return NextResponse.json({ ok: true, ...result }); 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); await cancelExtensionRequest(maestroExtensionID);
logger.info(CTX, "cancelled", {
id: maestroExtensionID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof ExtensionRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
} }
} );
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getExtensionRequests } from "@/lib/extension-requests"; import { getExtensionRequests } from "@/lib/extension-requests";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) { export const GET = withApiHandler("extension-requests", async (request) => {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url); const url = new URL(request.url);
const result = await getExtensionRequests(url.searchParams); const result = await getExtensionRequests(url.searchParams);
return NextResponse.json(result); 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 });
}
);
+50 -29
View File
@@ -1,39 +1,60 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { getMaestroDetail, updateMaestro, updateMaestroSchema } from "@/lib/maestros";
import { getMaestroDetail } from "@/lib/maestros"; import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
type MaestroDetailRouteContext = { const CTX = "maestros/[id]";
params: Promise<{
id: string;
}>;
};
export async function GET( export const GET = withApiHandler<{ id: string }>(
_request: Request, CTX,
{ params }: MaestroDetailRouteContext async (_request, { params }) => {
) { const { id } = await params;
const session = await auth(); const maestroID = Number(id);
if (!session) { if (!Number.isInteger(maestroID) || maestroID <= 0) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); throw new ApiError("Invalid maestro id", 400);
}
const result = await getMaestroDetail(maestroID);
if (!result) {
throw new ApiError("Not found", 404);
}
return NextResponse.json(result);
} }
);
const { id } = await params; export const PATCH = withApiHandler<{ id: string }>(
const maestroID = Number(id); CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) { if (!Number.isInteger(maestroID) || maestroID <= 0) {
return NextResponse.json( throw new ApiError("Invalid maestro id", 400);
{ message: "Invalid maestro id" }, }
{ status: 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);
} }
);
const result = await getMaestroDetail(maestroID);
if (!result) {
return NextResponse.json({ message: "Not found" }, { status: 404 });
}
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 });
}
);
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMaestros } from "@/lib/maestros"; import { getMaestros } from "@/lib/maestros";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) { export const GET = withApiHandler("maestros", async (request) => {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url); const url = new URL(request.url);
const result = await getMaestros(url.searchParams); const result = await getMaestros(url.searchParams);
return NextResponse.json(result); return NextResponse.json(result);
} });
+38 -44
View File
@@ -1,67 +1,61 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/auth";
import { import {
approveUpgradeRequest, approveUpgradeRequest,
rejectUpgradeRequest, rejectUpgradeRequest,
UpgradeRequestActionError,
} from "@/lib/upgrade-requests"; } 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({ const patchBodySchema = z.object({
action: z.enum(["approve", "reject"]), action: z.enum(["approve", "reject"]),
}); });
type UpgradeRequestRouteContext = { export const PATCH = withApiHandler<{ id: string }>(
params: Promise<{ CTX,
id: string; async (request, { params, t0 }) => {
}>; const { id } = await params;
}; const maestroUpgradeID = Number(id);
export async function PATCH( if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
request: Request, throw new ApiError("Invalid upgrade request id", 400);
{ params }: UpgradeRequestRouteContext }
) {
const session = await auth();
if (!session) { const body = patchBodySchema.safeParse(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); await request.json().catch(() => ({}))
}
const { id } = await params;
const maestroUpgradeID = Number(id);
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
return NextResponse.json(
{ message: "Invalid upgrade request id" },
{ status: 400 }
); );
} if (!body.success) {
throw new ApiError("Invalid action", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
}
try {
if (body.data.action === "approve") { if (body.data.action === "approve") {
const result = await approveUpgradeRequest(maestroUpgradeID); const result = await approveUpgradeRequest(maestroUpgradeID);
return NextResponse.json({ ok: true, ...result }); 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); await rejectUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "rejected", {
id: maestroUpgradeID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof UpgradeRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
} }
} );
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getUpgradeRequests } from "@/lib/upgrade-requests"; import { getUpgradeRequests } from "@/lib/upgrade-requests";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) { export const GET = withApiHandler("upgrade-requests", async (request) => {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url); const url = new URL(request.url);
const result = await getUpgradeRequests(url.searchParams); const result = await getUpgradeRequests(url.searchParams);
return NextResponse.json(result); return NextResponse.json(result);
} });
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 97 KiB

+56 -1
View File
@@ -3,6 +3,35 @@ import Credentials from "next-auth/providers/credentials";
import { z } from "zod"; import { z } from "zod";
import { ACTIVATE_STATUSES } from "@/lib/constants"; 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({ const credentialsSchema = z.object({
adminName: z.string().trim().min(1), adminName: z.string().trim().min(1),
@@ -39,6 +68,10 @@ declare module "next-auth/jwt" {
} }
} }
const appEnv = process.env.APP_ENV ?? "local";
const isSecure = process.env.NODE_ENV === "production";
const sessionCookieName = `chocoadmin-${appEnv}.session-token`;
export const { export const {
handlers: { GET, POST }, handlers: { GET, POST },
auth, auth,
@@ -50,6 +83,18 @@ export const {
}, },
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 8 * 60 * 60,
},
cookies: {
sessionToken: {
name: sessionCookieName,
options: {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: isSecure,
},
},
}, },
providers: [ providers: [
Credentials({ Credentials({
@@ -64,8 +109,14 @@ export const {
return null; return null;
} }
const { db } = await import("@/lib/db");
const { adminName, password } = parsedCredentials.data; const { adminName, password } = parsedCredentials.data;
if (isLockedOut(adminName)) {
logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName });
return null;
}
const { db } = await import("@/lib/db");
const admins = await db.$queryRaw<AdminAuthRow[]>` const admins = await db.$queryRaw<AdminAuthRow[]>`
SELECT AdminID, Name, Email, AccountType, ActivateStatus SELECT AdminID, Name, Email, AccountType, ActivateStatus
FROM admin FROM admin
@@ -78,9 +129,13 @@ export const {
const activateStatus = Number(admin?.ActivateStatus); const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
recordFailure(adminName);
logger.warn(AUTH_CTX, "login failed", { adminName });
return null; return null;
} }
clearAttempts(adminName);
logger.info(AUTH_CTX, "login success", { adminName, adminID });
return { return {
id: String(adminID), id: String(adminID),
name: admin.Name, name: admin.Name,
@@ -108,19 +108,16 @@ function ExtensionRequestActions({
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED; const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) { if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>; return <span className="text-xs text-muted-foreground"> </span>;
} }
async function submitAction(action: "approve" | "cancel") { async function submitAction(action: "approve" | "cancel") {
const actionLabel = action === "approve" ? "승인" : "취소"; const actionLabel = action === "approve" ? "승인" : "취소";
const confirmed = window.confirm( if (!window.confirm(`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`)) {
`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return; return;
} }
@@ -136,14 +133,19 @@ function ExtensionRequestActions({
} }
); );
if (!response.ok) { const body = (await response.json().catch(() => null)) as {
const body = (await response.json().catch(() => null)) as { message?: string;
message?: string; emailSent?: boolean;
} | null; } | null;
if (!response.ok) {
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다."); throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
} }
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
}
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
}); });
@@ -158,27 +160,36 @@ function ExtensionRequestActions({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> {isRequested ? (
<Button <div className="flex items-center gap-2">
disabled={isPending} <Button
onClick={() => void submitAction("approve")} disabled={isPending}
size="sm" onClick={() => void submitAction("approve")}
type="button" size="sm"
> type="button"
<Check className="size-4" aria-hidden="true" /> >
<Check className="size-4" aria-hidden="true" />
</Button>
<Button </Button>
disabled={isPending} <Button
onClick={() => void submitAction("cancel")} disabled={isPending}
size="sm" onClick={() => void submitAction("cancel")}
type="button" size="sm"
variant="outline" type="button"
> variant="outline"
<X className="size-4" aria-hidden="true" /> >
<X className="size-4" aria-hidden="true" />
</Button>
</div> </Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? ( {errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p> <p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null} ) : null}
@@ -10,7 +10,6 @@ import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { UpgradeRequestListItem } from "@/lib/upgrade-requests"; import type { UpgradeRequestListItem } from "@/lib/upgrade-requests";
import { import {
formatDate, formatDate,
formatDateTime,
getAccountTypeLabel, getAccountTypeLabel,
getActivateStatusLabel, getActivateStatusLabel,
getRequestStatusLabel, getRequestStatusLabel,
@@ -79,9 +78,7 @@ export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) {
{request.additionalPrice.toLocaleString()} {request.additionalPrice.toLocaleString()}
</span> </span>
</BodyCell> </BodyCell>
<BodyCell> <BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
{formatDateTime(request.requestedDateTime)}
</BodyCell>
<BodyCell> <BodyCell>
{formatDate(request.availableActivateDateTime)} {formatDate(request.availableActivateDateTime)}
</BodyCell> </BodyCell>
@@ -116,19 +113,16 @@ function UpgradeRequestActions({
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED; const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) { if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>; return <span className="text-xs text-muted-foreground"> </span>;
} }
async function submitAction(action: "approve" | "reject") { async function submitAction(action: "approve" | "reject") {
const actionLabel = action === "approve" ? "승인" : "거절"; const actionLabel = action === "approve" ? "승인" : "거절";
const confirmed = window.confirm( if (!window.confirm(`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`)) {
`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return; return;
} }
@@ -144,14 +138,17 @@ function UpgradeRequestActions({
} }
); );
if (!response.ok) { const body = (await response.json().catch(() => null)) as {
const body = (await response.json().catch(() => null)) as { message?: string;
message?: string; emailSent?: boolean;
} | null; } | null;
throw new Error( if (!response.ok) {
body?.message ?? "업그레이드 신청 처리에 실패했습니다." throw new Error(body?.message ?? "업그레이드 신청 처리에 실패했습니다.");
); }
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
} }
startTransition(() => { startTransition(() => {
@@ -168,27 +165,36 @@ function UpgradeRequestActions({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> {isRequested ? (
<Button <div className="flex items-center gap-2">
disabled={isPending} <Button
onClick={() => void submitAction("approve")} disabled={isPending}
size="sm" onClick={() => void submitAction("approve")}
type="button" size="sm"
> type="button"
<Check className="size-4" aria-hidden="true" /> >
<Check className="size-4" aria-hidden="true" />
</Button>
<Button </Button>
disabled={isPending} <Button
onClick={() => void submitAction("reject")} disabled={isPending}
size="sm" onClick={() => void submitAction("reject")}
type="button" size="sm"
variant="outline" type="button"
> variant="outline"
<X className="size-4" aria-hidden="true" /> >
<X className="size-4" aria-hidden="true" />
</Button>
</div> </Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? ( {errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p> <p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null} ) : null}
+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
+12 -2
View File
@@ -1,10 +1,12 @@
services: services:
chocoadmin: # 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
chocoadmin-stage:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: chocoadmin-stage:latest image: chocoadmin-stage:latest
container_name: stage-chocoadmin container_name: chocoadmin-stage
expose: expose:
- "3000" - "3000"
env_file: env_file:
@@ -14,9 +16,17 @@ services:
APP_ENV: stage APP_ENV: stage
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks: networks:
- proxy-network - proxy-network
restart: unless-stopped restart: unless-stopped
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks: networks:
proxy-network: proxy-network:
+14 -3
View File
@@ -1,21 +1,32 @@
services: services:
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 stage(chocoadmin-stage)와 겹치면 안 된다.
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
chocoadmin: chocoadmin:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: chocoadmin:latest image: chocoadmin:latest
container_name: chocoadmin container_name: chocoadmin
ports: expose:
- "3000:3000" - "3000"
env_file: env_file:
- ${ENV_FILE:-.env.production} - .env.production
environment: environment:
NODE_ENV: production NODE_ENV: production
APP_ENV: production
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
networks: networks:
- proxy-network - proxy-network
restart: unless-stopped restart: unless-stopped
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks: networks:
proxy-network: proxy-network:
+171 -24
View File
@@ -1,43 +1,141 @@
# chocoadmin Deployment # chocoadmin Deployment
## 1. Production Environment ## 1. Environment Files
Create `.env.production` on the production host. Do not commit it. Create environment files on the Synology NAS. Do not commit real `.env.*` files.
Stage file path:
```bash ```bash
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@AWS_EC2_HOST:3306/chocomae" /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" AUTH_SECRET="replace-with-production-secret"
NEXTAUTH_SECRET="replace-with-production-secret" NEXTAUTH_SECRET="replace-with-production-secret"
NEXTAUTH_URL="http://NAS_HOST_OR_DOMAIN:3000" AUTH_URL="https://chocoadmin.jinaju.com"
NEXTAUTH_URL="https://chocoadmin.jinaju.com"
``` ```
Create `.env.stage` on the stage host with the same keys and stage-specific values. `AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
`AUTH_SECRET` and `NEXTAUTH_SECRET` should use the same strong random value per environment for Auth.js compatibility. `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.
## 2. Synology Container Manager When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
1. Copy the repository to the NAS. Generate a secret on the NAS:
2. Place `.env.production` in the repository root on the NAS.
3. In Container Manager, create a project from `docker-compose.yml`.
4. Build and start the project.
5. Open `http://NAS_HOST_OR_DOMAIN:3000/login`.
Equivalent CLI command:
```bash ```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 up -d --build
docker compose ps
docker compose logs -f chocoadmin
``` ```
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from `.env.production` by default. After redeployment, reload NPM:
To run with stage settings:
```bash ```bash
ENV_FILE=.env.stage docker compose up -d --build docker exec npm nginx -s reload
``` ```
## 3. AWS EC2 Security Group 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. Allow MariaDB only from the Synology NAS public IP.
@@ -49,7 +147,7 @@ Allow MariaDB only from the Synology NAS public IP.
Do not open `3306` to `0.0.0.0/0`. Do not open `3306` to `0.0.0.0/0`.
## 4. Read-Only Rehearsal ## 5. Read-Only Rehearsal
Before enabling approval/rejection operations against production DB: Before enabling approval/rejection operations against production DB:
@@ -59,13 +157,13 @@ Before enabling approval/rejection operations against production DB:
4. Verify login, maestro list, extension request list, and upgrade request list. 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. 5. Switch to the production write-capable chocoadmin DB account only after read screens work.
## 5. Smoke Checks ## 6. Smoke Checks
After container start: After each deployment:
```bash ```bash
docker compose ps docker compose -f docker-compose.stage.yml ps # stage
docker compose logs -f chocoadmin docker compose ps # production
``` ```
Verify these routes in the browser: Verify these routes in the browser:
@@ -74,3 +172,52 @@ Verify these routes in the browser:
- `/maestros` - `/maestros`
- `/extension-requests` - `/extension-requests`
- `/upgrade-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.
+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 위험은 낮음.
+260 -13
View File
@@ -37,6 +37,7 @@
| ORM | Prisma (기존 DB introspection 방식으로 활용) | | ORM | Prisma (기존 DB introspection 방식으로 활용) |
| 유효성 검증 | Zod | | 유효성 검증 | Zod |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) | | 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 이메일 발송 | Nodemailer (기존 `mouse-typing` SMTP 설정을 환경변수로 이관) |
| 환경변수 관리 | `.env.local` / `.env.production` | | 환경변수 관리 | `.env.local` / `.env.production` |
### Prisma vs Drizzle ORM ### Prisma vs Drizzle ORM
@@ -158,10 +159,16 @@ chocoadmin/
│ │ └── page.tsx # 관리자 로그인 │ │ └── page.tsx # 관리자 로그인
│ ├── (admin)/ │ ├── (admin)/
│ │ ├── layout.tsx # 공통 사이드바 + 헤더 │ │ ├── layout.tsx # 공통 사이드바 + 헤더
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
│ │ ├── maestros/ │ │ ├── maestros/
│ │ │ ├── page.tsx # 마에스트로 전체 목록 │ │ │ ├── page.tsx # 마에스트로 전체 목록
│ │ │ └── [maestroId]/ │ │ │ └── [maestroId]/
│ │ │ ── page.tsx # 마에스트로 상세 정보 │ │ │ ── page.tsx # 마에스트로 상세 정보 (server)
│ │ │ ├── MaestroEditForm.tsx # 기본 정보 수정 폼 (client)
│ │ │ ├── StudentsSection.tsx # 학생 목록 + 검색/페이지네이션 (client)
│ │ │ ├── ExtensionRequestsSection.tsx # 연장 신청 이력 + [연장] 버튼 (client)
│ │ │ └── UpgradeRequestsSection.tsx # 업그레이드 신청 이력 + 신청 UI (client)
│ │ ├── extension-requests/ │ │ ├── extension-requests/
│ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소 │ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소
│ │ └── upgrade-requests/ │ │ └── upgrade-requests/
@@ -170,7 +177,12 @@ chocoadmin/
│ ├── auth/[...nextauth]/ │ ├── auth/[...nextauth]/
│ ├── maestros/ │ ├── maestros/
│ │ ├── route.ts # GET: 목록 │ │ ├── route.ts # GET: 목록
│ │ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경 │ │ └── [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/ │ ├── extension-requests/
│ │ └── [id]/route.ts # PATCH: 승인/취소 │ │ └── [id]/route.ts # PATCH: 승인/취소
│ └── upgrade-requests/ │ └── upgrade-requests/
@@ -184,6 +196,7 @@ chocoadmin/
├── lib/ ├── lib/
│ ├── db.ts # Prisma Client 싱글톤 │ ├── db.ts # Prisma Client 싱글톤
│ ├── auth.ts # NextAuth 설정 │ ├── auth.ts # NextAuth 설정
│ ├── mail.ts # chocomae SMTP 메일 발송 모듈
│ ├── validators/ # Zod 스키마 │ ├── validators/ # Zod 스키마
│ └── constants.ts # 상태값 상수 │ └── constants.ts # 상태값 상수
├── types/ ├── types/
@@ -222,9 +235,26 @@ chocoadmin/
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`) ### 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`) - 관리자 처리 로그 (`maestro_log`)
### 7-4. 연장 신청 목록 (`/extension-requests`) ### 7-4. 연장 신청 목록 (`/extension-requests`)
@@ -240,6 +270,12 @@ chocoadmin/
4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용) 4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용)
5. `maestro_log` 기록 5. `maestro_log` 기록
6. 트랜잭션 커밋 6. 트랜잭션 커밋
7. 커밋 후 연장 완료 이메일 자동 발송
**연장 완료 이메일:**
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php:29`
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정 유효기간 연장 완료`
- 내용: 유효기간 +1년 연장 완료, 새 유효기간 안내
**취소 처리 순서:** **취소 처리 순서:**
1. `maestro_extension.Status = 1` 인지 확인 1. `maestro_extension.Status = 1` 인지 확인
@@ -261,6 +297,12 @@ chocoadmin/
4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용) 4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용)
5. `maestro_log` 기록 5. `maestro_log` 기록
6. 트랜잭션 커밋 6. 트랜잭션 커밋
7. 커밋 후 업그레이드 완료 이메일 자동 발송
**업그레이드 완료 이메일:**
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php:30`
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
- 내용: 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년
**거절 처리 순서:** **거절 처리 순서:**
1. `maestro_upgrade.Status = 1` 인지 확인 1. `maestro_upgrade.Status = 1` 인지 확인
@@ -280,6 +322,7 @@ chocoadmin/
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다. 5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다. 6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다. 7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
8. SMTP/API 인증 정보, 발송자 주소, BCC 주소는 환경변수 또는 배포 시크릿으로 관리하고 코드에 하드코딩하지 않는다.
--- ---
@@ -348,16 +391,220 @@ chocoadmin/
- [x] Dockerfile (프로덕션용 pnpm 빌드) - [x] Dockerfile (프로덕션용 pnpm 빌드)
- [x] `docker-compose.yml` (Synology NAS Container Manager용) - [x] `docker-compose.yml` (Synology NAS Container Manager용)
- [ ] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용)`docs/deployment.md`에 절차 문서화 - [x] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용)
- [ ] Synology NAS에서 컨테이너 실행 테스트 — `docs/deployment.md`에 절차 문서화 - [x] Synology NAS에서 컨테이너 실행 확인 (production + stage 동시 운영 중)
- [ ] 운영 DB read-only 리허설 후 변경 기능 활성화 — `docs/deployment.md`에 절차 문서화 - [x] 운영 DB 연결 및 변경 기능 확인
### Phase 8. 검증 및 안정화 ### Phase 8. 검증 및 안정화
- [ ] 승인/취소/거절 트랜잭션 시나리오 테스트 - [x] 승인/취소/거절 트랜잭션 시나리오 테스트 절차 문서화 (`docs/phase/phase8.md`)
- [ ] 중복 처리 방지 (이미 처리된 건 재처리 시 에러 확인) - [x] 중복 처리 방지 코드 확인 (이미 처리된 건 재처리 시 `409` 응답)
- [ ] 비인증 접근 차단 확인 - [x] 비인증 접근 차단 코드 확인 (`proxy.ts`, API route `auth()` 검사)
- [ ] Docker 재시작 후 정상 동작 확인 - [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`
--- ---
@@ -372,8 +619,8 @@ chocoadmin/
- Docker 기반 NAS 배포 - Docker 기반 NAS 배포
**MVP 이후 확장 후보:** **MVP 이후 확장 후보:**
- 마에스트로 상태 직접 변경
- 학생 목록 상세 관리 - 학생 목록 상세 관리
- 이메일 발송 이력/재발송 관리
- 데이터 내보내기 (CSV) - 데이터 내보내기 (CSV)
- 처리 통계 대시보드 - 처리 통계 대시보드
- 감사 로그 전용 화면 - 감사 로그 전용 화면
+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 }
);
}
};
}
+8
View File
@@ -68,6 +68,14 @@ export const REQUEST_STATUS_LABELS = {
[REQUEST_STATUSES.APPLIED]: "적용 완료", [REQUEST_STATUSES.APPLIED]: "적용 완료",
} as const; } 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 AccountType = (typeof ACCOUNT_TYPES)[keyof typeof ACCOUNT_TYPES];
export type PaidAccountType = export type PaidAccountType =
| typeof ACCOUNT_TYPES.BASIC_20 | typeof ACCOUNT_TYPES.BASIC_20
+244 -3
View File
@@ -1,11 +1,227 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { writeSync } from "fs";
import { PrismaClient } from "@/lib/generated/prisma/client"; 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 { const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient; 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() { function createPrismaClient() {
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
@@ -14,8 +230,33 @@ function createPrismaClient() {
} }
const adapter = new PrismaMariaDb(databaseUrl); const adapter = new PrismaMariaDb(databaseUrl);
const baseClient = new PrismaClient({
adapter,
log: [{ emit: "event", level: "query" }],
});
return new PrismaClient({ adapter }); 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(); export const db = globalForPrisma.prisma ?? createPrismaClient();
+9
View File
@@ -0,0 +1,9 @@
export class ApiError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ApiError";
}
}
+137 -52
View File
@@ -1,7 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { REQUEST_STATUSES } from "@/lib/constants"; 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 type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateExtendedAvailableDate, calculateExtendedAvailableDate,
@@ -20,7 +22,7 @@ const extensionRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -51,22 +53,13 @@ export type ExtensionRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export class ExtensionRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ExtensionRequestActionError";
}
}
export async function getExtensionRequests( export async function getExtensionRequests(
rawSearchParams: RawSearchParams = {} rawSearchParams: RawSearchParams = {}
): Promise<ExtensionRequestListResult> { ): Promise<ExtensionRequestListResult> {
const t0 = Date.now();
const params = parseExtensionRequestSearchParams(rawSearchParams); const params = parseExtensionRequestSearchParams(rawSearchParams);
const where = buildExtensionRequestWhere(params); const where = buildExtensionRequestWhere(params);
const skip = (params.page - 1) * params.pageSize; const skip = (params.page - 1) * params.pageSize;
@@ -96,6 +89,12 @@ export async function getExtensionRequests(
}), }),
]); ]);
logger.info("extension-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return { return {
items: requests.map(toExtensionRequestListItem), items: requests.map(toExtensionRequestListItem),
page: params.page, page: params.page,
@@ -109,45 +108,133 @@ export async function getExtensionRequests(
export async function approveExtensionRequest( export async function approveExtensionRequest(
maestroExtensionID: number maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }> { ): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: {
select: {
Name: true,
Email: true,
AvailableActivateDateTime: true,
ActivateStatus: true,
},
},
},
});
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
}
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
request.maestro.AvailableActivateDateTime request!.maestro.AvailableActivateDateTime
); );
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
ActivateStatus: 2, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_extension.updateMany({ await tx.maestro_extension.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "extension_maestro", Type: "extension_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildExtensionLogRemark( Remark: buildExtensionLogRemark(
request.maestro.Name, request!.maestro.Name,
request.AccountType request!.AccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request!.maestro.Name.trim(),
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,
}; };
}); });
} }
@@ -156,20 +243,37 @@ export async function cancelExtensionRequest(
maestroExtensionID: number maestroExtensionID: number
): Promise<void> { ): Promise<void> {
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID); const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: { select: { Name: true } },
},
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "cancel_extension_maestro", Type: "cancel_extension_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildExtensionLogRemark( Remark: buildExtensionLogRemark(
request.maestro.Name, request!.maestro.Name,
request.AccountType request!.AccountType
), ),
}, },
}); });
@@ -184,25 +288,6 @@ export function parseExtensionRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroExtensionID: number
) {
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
include: { maestro: true },
});
if (!request) {
throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409);
}
return request;
}
function buildExtensionRequestWhere( function buildExtensionRequestWhere(
params: ExtensionRequestSearchParams params: ExtensionRequestSearchParams
+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),
};
+364 -23
View File
@@ -1,7 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; 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 type { Prisma } from "@/lib/generated/prisma/client";
import { formatDate } from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const; const pageSizeOptions = [10, 20, 50, 100] as const;
const sortOptions = [ const sortOptions = [
@@ -71,7 +75,6 @@ export type MaestroDetail = {
players: number; players: number;
extensionRequests: number; extensionRequests: number;
upgradeRequests: number; upgradeRequests: number;
logs: number;
}; };
players: Array<{ players: Array<{
playerID: number; playerID: number;
@@ -93,17 +96,363 @@ export type MaestroDetail = {
requestedDateTime: string; requestedDateTime: string;
status: number; status: number;
}>; }>;
logs: Array<{ };
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; maestroLogID: number;
type: string; type: string;
logDateTime: string; logDateTime: string;
remark: 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( export async function getMaestros(
rawSearchParams: RawSearchParams = {} rawSearchParams: RawSearchParams = {}
): Promise<MaestroListResult> { ): Promise<MaestroListResult> {
const t0 = Date.now();
const params = parseMaestroListSearchParams(rawSearchParams); const params = parseMaestroListSearchParams(rawSearchParams);
const where = buildMaestroWhere(params); const where = buildMaestroWhere(params);
const orderBy = buildMaestroOrderBy(params.sort); const orderBy = buildMaestroOrderBy(params.sort);
@@ -131,6 +480,12 @@ export async function getMaestros(
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize)); const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
logger.info("maestros", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return { return {
items: maestros.map(toMaestroListItem), items: maestros.map(toMaestroListItem),
page: params.page, page: params.page,
@@ -147,6 +502,7 @@ export async function getMaestros(
export async function getMaestroDetail( export async function getMaestroDetail(
maestroID: number maestroID: number
): Promise<MaestroDetail | null> { ): Promise<MaestroDetail | null> {
const t0 = Date.now();
const maestro = await db.maestro.findUnique({ const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID }, where: { MaestroID: maestroID },
select: { select: {
@@ -164,6 +520,7 @@ export async function getMaestroDetail(
}); });
if (!maestro) { if (!maestro) {
logger.warn("maestros/[id]", "not found", { id: maestroID });
return null; return null;
} }
@@ -171,16 +528,13 @@ export async function getMaestroDetail(
playersCount, playersCount,
extensionRequestsCount, extensionRequestsCount,
upgradeRequestsCount, upgradeRequestsCount,
logsCount,
players, players,
extensionRequests, extensionRequests,
upgradeRequests, upgradeRequests,
logs,
] = await Promise.all([ ] = await Promise.all([
db.player.count({ where: { MaestroID: maestroID } }), db.player.count({ where: { MaestroID: maestroID } }),
db.maestro_extension.count({ where: { MaestroID: maestroID } }), db.maestro_extension.count({ where: { MaestroID: maestroID } }),
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }), db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
db.maestro_log.count({ where: { MaestroID: maestroID } }),
db.player.findMany({ db.player.findMany({
where: { MaestroID: maestroID }, where: { MaestroID: maestroID },
orderBy: { PlayerID: "desc" }, orderBy: { PlayerID: "desc" },
@@ -216,19 +570,13 @@ export async function getMaestroDetail(
Status: true, Status: true,
}, },
}), }),
db.maestro_log.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroLogID: "desc" },
take: 30,
select: {
MaestroLogID: true,
Type: true,
LogDateTime: true,
Remark: true,
},
}),
]); ]);
logger.info("maestros/[id]", "fetched", {
id: maestroID,
duration: Date.now() - t0,
});
return { return {
maestro: { maestro: {
...toMaestroListItem(maestro), ...toMaestroListItem(maestro),
@@ -239,7 +587,6 @@ export async function getMaestroDetail(
players: playersCount, players: playersCount,
extensionRequests: extensionRequestsCount, extensionRequests: extensionRequestsCount,
upgradeRequests: upgradeRequestsCount, upgradeRequests: upgradeRequestsCount,
logs: logsCount,
}, },
players: players.map((player) => ({ players: players.map((player) => ({
playerID: player.PlayerID, playerID: player.PlayerID,
@@ -261,12 +608,6 @@ export async function getMaestroDetail(
requestedDateTime: request.RequestedDateTime.toISOString(), requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status, status: request.Status,
})), })),
logs: logs.map((log) => ({
maestroLogID: log.MaestroLogID,
type: log.Type.trim(),
logDateTime: log.LogDateTime.toISOString(),
remark: log.Remark.trim(),
})),
}; };
} }
+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 });
}
+165 -59
View File
@@ -1,10 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants"; 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 type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateUpgradePrice, calculateUpgradePrice,
formatDate,
getAccountTypeLabel, getAccountTypeLabel,
getAccountTypePrice, getAccountTypePrice,
getTrialAccountTypeLabel, getTrialAccountTypeLabel,
@@ -22,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -54,22 +57,13 @@ export type UpgradeRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export class UpgradeRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "UpgradeRequestActionError";
}
}
export async function getUpgradeRequests( export async function getUpgradeRequests(
rawSearchParams: RawSearchParams = {} rawSearchParams: RawSearchParams = {}
): Promise<UpgradeRequestListResult> { ): Promise<UpgradeRequestListResult> {
const t0 = Date.now();
const params = parseUpgradeRequestSearchParams(rawSearchParams); const params = parseUpgradeRequestSearchParams(rawSearchParams);
const where = buildUpgradeRequestWhere(params); const where = buildUpgradeRequestWhere(params);
const skip = (params.page - 1) * params.pageSize; const skip = (params.page - 1) * params.pageSize;
@@ -99,6 +93,12 @@ export async function getUpgradeRequests(
}), }),
]); ]);
logger.info("upgrade-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return { return {
items: requests.map(toUpgradeRequestListItem), items: requests.map(toUpgradeRequestListItem),
page: params.page, page: params.page,
@@ -112,45 +112,157 @@ export async function getUpgradeRequests(
export async function approveUpgradeRequest( export async function approveUpgradeRequest(
maestroUpgradeID: number maestroUpgradeID: number
): Promise<{ availableActivateDateTime: string }> { ): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RequestedAccountType: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
maestro: { select: { Name: true, Email: true, AccountType: true, ActivateStatus: true } },
},
});
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
const isStillValidUpgrade = isApprovalTrial
? request!.RequestedAccountType >= request!.maestro.AccountType
: request!.RequestedAccountType > request!.maestro.AccountType;
if (!isStillValidUpgrade) {
throw new ApiError(
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
409
);
}
const availableActivateDateTime = calculateUpgradeAvailableDate(); const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
AccountType: request.RequestedAccountType, AccountType: request!.RequestedAccountType,
ActivateStatus: ACTIVATE_STATUSES.ACTIVE, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_upgrade.updateMany({ await tx.maestro_upgrade.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "upgrade_maestro", Type: "upgrade_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildUpgradeLogRemark( Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus, request!.RegisteredActivateStatus,
request.RegisteredAccountType, request!.RegisteredAccountType,
request.RequestedAccountType request!.RequestedAccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request!.maestro.Name.trim(),
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,
}; };
}); });
} }
@@ -159,21 +271,39 @@ export async function rejectUpgradeRequest(
maestroUpgradeID: number maestroUpgradeID: number
): Promise<void> { ): Promise<void> {
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID); const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
},
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "reject_upgrade_maestro", Type: "reject_upgrade_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildUpgradeLogRemark( Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus, request!.RegisteredActivateStatus,
request.RegisteredAccountType, request!.RegisteredAccountType,
request.RequestedAccountType request!.RequestedAccountType
), ),
}, },
}); });
@@ -188,30 +318,6 @@ export function parseUpgradeRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroUpgradeID: number
) {
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
});
if (!request) {
throw new UpgradeRequestActionError(
"업그레이드 신청을 찾을 수 없습니다.",
404
);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new UpgradeRequestActionError(
"이미 처리된 업그레이드 신청입니다.",
409
);
}
return request;
}
function buildUpgradeRequestWhere( function buildUpgradeRequestWhere(
params: UpgradeRequestSearchParams params: UpgradeRequestSearchParams
@@ -276,7 +382,7 @@ function toUpgradeRequestListItem(request: {
registeredActivateStatus: request.RegisteredActivateStatus, registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType, registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType, requestedAccountType: request.RequestedAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(), requestedDateTime: formatDate(request.RequestedDateTime),
availableActivateDateTime: availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(), request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status, status: request.Status,
+16
View File
@@ -104,6 +104,22 @@ export function calculateExtendedAvailableDate(
return extendedDate; 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 { function toDate(value: Date | string | null | undefined): Date | null {
if (!value) { if (!value) {
return null; return null;
+3
View File
@@ -23,9 +23,11 @@
"mariadb": "^3.5.2", "mariadb": "^3.5.2",
"next": "16.2.7", "next": "16.2.7",
"next-auth": "5.0.0-beta.31", "next-auth": "5.0.0-beta.31",
"nodemailer": "^9.0.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.11.0", "shadcn": "^4.11.0",
"sql-formatter": "^15.8.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -33,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
+85 -4
View File
@@ -37,7 +37,10 @@ importers:
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth: next-auth:
specifier: 5.0.0-beta.31 specifier: 5.0.0-beta.31
version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4)
nodemailer:
specifier: ^9.0.1
version: 9.0.1
react: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
@@ -47,6 +50,9 @@ importers:
shadcn: shadcn:
specifier: ^4.11.0 specifier: ^4.11.0
version: 4.11.0(typescript@5.9.3) version: 4.11.0(typescript@5.9.3)
sql-formatter:
specifier: ^15.8.2
version: 15.8.2
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@@ -63,6 +69,9 @@ importers:
'@types/node': '@types/node':
specifier: ^20 specifier: ^20
version: 20.19.42 version: 20.19.42
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/react': '@types/react':
specifier: ^19 specifier: ^19
version: 19.2.17 version: 19.2.17
@@ -1102,6 +1111,9 @@ packages:
'@types/node@24.13.1': '@types/node@24.13.1':
resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==} resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -1528,6 +1540,9 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'} engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1679,6 +1694,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
doctrine@2.1.0: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2650,6 +2668,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2674,6 +2695,10 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
negotiator@1.0.0: negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -2732,6 +2757,10 @@ packages:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@9.0.1:
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
engines: {node: '>=6.0.0'}
npm-run-path@4.0.1: npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2968,6 +2997,13 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
range-parser@1.2.1: range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3033,6 +3069,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
retry@0.12.0: retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -3155,6 +3195,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
sql-formatter@15.8.2:
resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==}
hasBin: true
sqlstring@2.3.3: sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3479,13 +3523,15 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@auth/core@0.41.2': '@auth/core@0.41.2(nodemailer@9.0.1)':
dependencies: dependencies:
'@panva/hkdf': 1.2.1 '@panva/hkdf': 1.2.1
jose: 6.2.3 jose: 6.2.3
oauth4webapi: 3.8.6 oauth4webapi: 3.8.6
preact: 10.24.3 preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3) preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 9.0.1
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
dependencies: dependencies:
@@ -4376,6 +4422,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 20.19.42
'@types/react-dom@19.2.3(@types/react@19.2.17)': '@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies: dependencies:
'@types/react': 19.2.17 '@types/react': 19.2.17
@@ -4803,6 +4853,8 @@ snapshots:
commander@14.0.3: {} commander@14.0.3: {}
commander@2.20.3: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
confbox@0.2.4: {} confbox@0.2.4: {}
@@ -4914,6 +4966,8 @@ snapshots:
diff@8.0.4: {} diff@8.0.4: {}
discontinuous-range@1.0.0: {}
doctrine@2.1.0: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
@@ -6019,6 +6073,8 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
moo@0.5.3: {}
ms@2.1.3: {} ms@2.1.3: {}
mysql2@3.15.3: mysql2@3.15.3:
@@ -6043,13 +6099,22 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
negotiator@1.0.0: {} negotiator@1.0.0: {}
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4):
dependencies: dependencies:
'@auth/core': 0.41.2 '@auth/core': 0.41.2(nodemailer@9.0.1)
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
optionalDependencies:
nodemailer: 9.0.1
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
@@ -6092,6 +6157,8 @@ snapshots:
node-releases@2.0.47: {} node-releases@2.0.47: {}
nodemailer@9.0.1: {}
npm-run-path@4.0.1: npm-run-path@4.0.1:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -6340,6 +6407,13 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
range-parser@1.2.1: {} range-parser@1.2.1: {}
raw-body@3.0.2: raw-body@3.0.2:
@@ -6417,6 +6491,8 @@ snapshots:
onetime: 7.0.0 onetime: 7.0.0
signal-exit: 4.1.0 signal-exit: 4.1.0
ret@0.1.15: {}
retry@0.12.0: {} retry@0.12.0: {}
reusify@1.1.0: {} reusify@1.1.0: {}
@@ -6632,6 +6708,11 @@ snapshots:
source-map@0.6.1: {} source-map@0.6.1: {}
sql-formatter@15.8.2:
dependencies:
argparse: 2.0.1
nearley: 2.20.1
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
+3 -1
View File
@@ -12,10 +12,12 @@ function usesSecureAuthCookies() {
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
const isPublicPath = PUBLIC_PATHS.includes(pathname); const isPublicPath = PUBLIC_PATHS.includes(pathname);
const cookieName = `chocoadmin-${process.env.APP_ENV ?? "local"}.session-token`;
const token = await getToken({ const token = await getToken({
req: request, req: request,
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET, secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
secureCookie: usesSecureAuthCookies(), secureCookie: usesSecureAuthCookies(),
cookieName,
}); });
if (!token && !isPublicPath) { if (!token && !isPublicPath) {
@@ -33,5 +35,5 @@ export async function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"], matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
}; };
+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"