Compare commits

...

37 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
44 changed files with 2470 additions and 445 deletions
+50 -4
View File
@@ -11,13 +11,21 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
## Critical rules ## Critical rules
- **Never alter the DB schema.** All tables are shared with the live chocomae service. - **Never alter the DB schema.** All tables are shared with the live chocomae service.
- **All mutating APIs must verify current `Status = 1` before acting** (idempotency guard, returns 409 if already processed). - **Race-condition guard for approve/cancel/reject**: use a conditional `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })` as the first step inside a transaction. If `count === 0`, check existence to return 404 vs 409. Never use read-then-write (findUnique → check Status → update) — it allows double-processing under concurrent requests.
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically. - **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
- **Send email only after a successful commit**, never inside the transaction. - **Send email only after a successful commit**, never inside the transaction.
- **Never use `include: { maestro: true }`** when querying `maestro_extension` or `maestro_upgrade`. Always use `select` with only the needed columns to avoid logging the maestro `Password` field.
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan. - Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year. - Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade. - Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`. - When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
- **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 ## Authentication
@@ -25,6 +33,10 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- Config lives in `auth.ts` (project root), not inside `app/`. - Config lives in `auth.ts` (project root), not inside `app/`.
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`. - Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
- API routes must call `auth()` and return 401 if no session. - API routes must call `auth()` and return 401 if no session.
- Session `maxAge` is 8 hours — do not lengthen without review.
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
- 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 ## Key files
@@ -36,7 +48,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` | | `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/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/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`) | | `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`); `PAID_ACCOUNT_TYPE_VALUES` (유료 계정 유형 배열 — 중복 정의 방지용 공용 상수) |
| `lib/maestros.ts` | Maestro DB queries | | `lib/maestros.ts` | Maestro DB queries |
| `lib/extension-requests.ts` | Extension request queries and approval logic | | `lib/extension-requests.ts` | Extension request queries and approval logic |
| `lib/upgrade-requests.ts` | Upgrade request queries and approval logic | | `lib/upgrade-requests.ts` | Upgrade request queries and approval logic |
@@ -60,6 +72,9 @@ Always record a log entry after a successful DB change. Use these `Type` values:
| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) | | `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) |
| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` | | `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) | | `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
| `reset_maestro_password` | Maestro password reset to 123456 by admin | `admin reset to 123456` |
`Remark` column is `char(100)` — keep values under 100 characters. `Remark` column is `char(100)` — keep values under 100 characters.
@@ -67,11 +82,42 @@ Always record a log entry after a successful DB change. Use these `Type` values:
- Phase 08: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. - 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 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) ## Planned phases (not yet implemented)
- **Phase 10** — Maestro info edit form + student list with pagination (`PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students`) 이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
- **Phase 11** — Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`)
## Packages to know ## Packages to know
+31 -8
View File
@@ -19,13 +19,19 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스
## 구현된 기능 ## 구현된 기능
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환) - 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환, 로그인 실패 5회 → 15분 잠금)
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션 - 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그 - 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`) - 마에스트로 정보 수정 — 이름, 이메일, 상태, 계정유형, 만료일, 엔터코드 허용 여부 (`PATCH /api/maestros/[id]`)
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`) - 마에스트로 암호 초기화 — 관리자가 `123456`으로 초기화 (트랜잭션 + `maestro_log`)
- 수강생 목록 — 서버사이드 페이지네이션 (`GET /api/maestros/[id]/students`)
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log` + 이메일 발송)
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log` + 이메일 발송)
- 관리자 주도 연장/업그레이드 신청 등록 — 마에스트로 상세 화면에서 직접 등록 (이메일 미발송)
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사) - 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹 - 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
- 이메일 자동 발송 — 연장/업그레이드 승인 후 Nodemailer로 발송, 실패 시 UI 경고 표시
## 개발 환경 설정 ## 개발 환경 설정
@@ -86,15 +92,21 @@ pnpm dev
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) | | `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) | | `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) | | `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
| `MAIL_OVERRIDE_TO` | — | 설정 시 모든 메일 수신인을 이 주소로 고정 — local/stage 테스트용 (Phase 11) |
## 배포 (Synology NAS Docker) ## 배포 (Synology NAS Docker)
```bash ```bash
# 이미지 빌드 및 컨테이너 시작 # 스테이지 배포
docker compose up -d --build bash scripts/deploy-stage.sh
# 운영 배포 (yes 입력 확인 필요)
bash scripts/deploy-production.sh
``` ```
`docker-compose.yml` `environment` 섹션에 위 환경변수를 설정한다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다. 각 스크립트는 `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)를 참고한다.
## 프로젝트 구조 ## 프로젝트 구조
@@ -103,10 +115,18 @@ chocoadmin/
├── app/ ├── app/
│ ├── (auth)/login/ # 관리자 로그인 │ ├── (auth)/login/ # 관리자 로그인
│ ├── (admin)/ # 인증 필요 화면 │ ├── (admin)/ # 인증 필요 화면
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
│ │ ├── maestros/ # 마에스트로 목록·상세 │ │ ├── maestros/ # 마에스트로 목록·상세
│ │ ├── extension-requests/ │ │ ├── extension-requests/
│ │ └── upgrade-requests/ │ │ └── upgrade-requests/
│ └── api/ # API routes │ └── api/ # API routes
│ └── maestros/[id]/
│ ├── route.ts # PATCH — 마에스트로 정보 수정
│ ├── students/route.ts # GET — 수강생 목록 (페이지네이션)
│ ├── extension-requests/ # POST — 관리자 주도 연장 신청
│ ├── upgrade-requests/ # POST — 관리자 주도 업그레이드 신청
│ └── reset-password/ # POST — 암호 초기화
├── components/ ├── components/
│ ├── data-table/ # TanStack Table 기반 테이블 │ ├── data-table/ # TanStack Table 기반 테이블
│ ├── forms/ │ ├── forms/
@@ -116,10 +136,14 @@ chocoadmin/
│ ├── logger.ts # 공통 로거 (debug/info/warn/error) │ ├── logger.ts # 공통 로거 (debug/info/warn/error)
│ ├── errors.ts # ApiError 공통 에러 클래스 │ ├── errors.ts # ApiError 공통 에러 클래스
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리) │ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
│ ├── mail.ts # Nodemailer 이메일 발송
│ ├── constants.ts # 상태값 상수 │ ├── constants.ts # 상태값 상수
│ ├── maestros.ts # 마에스트로 DB 쿼리 │ ├── maestros.ts # 마에스트로 DB 쿼리
│ ├── extension-requests.ts │ ├── extension-requests.ts
│ └── upgrade-requests.ts │ └── upgrade-requests.ts
├── scripts/
│ ├── deploy-stage.sh # 스테이지 배포 스크립트
│ └── deploy-production.sh # 운영 배포 스크립트
├── prisma/ ├── prisma/
│ └── schema.prisma # DB introspection 결과 │ └── schema.prisma # DB introspection 결과
├── docs/ ├── docs/
@@ -136,4 +160,3 @@ chocoadmin/
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획 - [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙 - [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차 - [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
- [`docs/phase/phase9.md`](docs/phase/phase9.md) — 로그 정책 및 Docker 로그 운영 절차
+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
);
}
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Save } from "lucide-react"; import { KeyRound, Save } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -41,6 +41,7 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [name, setName] = useState(maestro.name); const [name, setName] = useState(maestro.name);
const [email, setEmail] = useState(maestro.email); const [email, setEmail] = useState(maestro.email);
@@ -56,7 +57,7 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [successMessage, setSuccessMessage] = useState(""); const [successMessage, setSuccessMessage] = useState("");
const isDisabled = isSaving || isPending; const isDisabled = isSaving || isPending || isResettingPassword;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -107,6 +108,42 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
} }
} }
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 ( return (
<form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5"> <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"> <div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
@@ -216,6 +253,16 @@ export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps)
<Save aria-hidden="true" className="size-4" /> <Save aria-hidden="true" className="size-4" />
</Button> </Button>
<Button
disabled={isDisabled}
onClick={() => void handleResetPassword()}
size="sm"
type="button"
variant="outline"
>
<KeyRound aria-hidden="true" className="size-4" />
</Button>
{successMessage ? ( {successMessage ? (
<p className="text-sm text-green-700 dark:text-green-400"> <p className="text-sm text-green-700 dark:text-green-400">
{successMessage} {successMessage}
@@ -246,7 +246,7 @@ function StudentPagination({
</label> </label>
{showNav ? <nav {showNav ? <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 ? (
<Button <Button
@@ -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>
);
}
+17 -108
View File
@@ -6,13 +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 { MaestroEditForm } from "./MaestroEditForm";
import { StudentsSection } from "./StudentsSection"; import { StudentsSection } from "./StudentsSection";
import { ExtensionRequestsSection } from "./ExtensionRequestsSection";
import { UpgradeRequestsSection } from "./UpgradeRequestsSection";
import { LogsSection } from "./LogsSection";
type MaestroDetailPageProps = { type MaestroDetailPageProps = {
params: Promise<{ params: Promise<{
@@ -92,59 +93,23 @@ export default async function MaestroDetailPage({
<StudentsSection maestroID={maestroID} /> <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>
); );
} }
@@ -157,59 +122,3 @@ function SummaryCard({ label, value }: { label: string; value: string }) {
</article> </article>
); );
} }
function SimpleTable({
headers,
rows,
emptyText,
}: {
headers: string[];
rows: Array<Array<string | number>>;
emptyText: string;
}) {
return (
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
{headers.map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
rows.map((row, index) => (
<tr className="border-b last:border-b-0" key={index}>
{row.map((cell, cellIndex) => (
<td
className="h-11 max-w-[360px] break-words px-3 align-middle"
key={cellIndex}
>
{cell}
</td>
))}
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={headers.length}
>
{emptyText}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+1 -1
View File
@@ -191,7 +191,7 @@ export default async function MaestrosPage({
</form> </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);
} }
+2 -2
View File
@@ -40,12 +40,12 @@ export const PATCH = withApiHandler<{ id: string }>(
id: maestroExtensionID, id: maestroExtensionID,
duration: Date.now() - t0, duration: Date.now() - t0,
}); });
void sendExtensionDoneEmail( const emailSent = await sendExtensionDoneEmail(
result.maestroName, result.maestroName,
result.maestroEmail, result.maestroEmail,
formatDate(result.availableActivateDateTime) formatDate(result.availableActivateDateTime)
); );
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime }); return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
} }
await cancelExtensionRequest(maestroExtensionID); await cancelExtensionRequest(maestroExtensionID);
@@ -0,0 +1,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 });
}
);
@@ -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 });
}
);
+2 -2
View File
@@ -40,7 +40,7 @@ export const PATCH = withApiHandler<{ id: string }>(
id: maestroUpgradeID, id: maestroUpgradeID,
duration: Date.now() - t0, duration: Date.now() - t0,
}); });
void sendUpgradeDoneEmail( const emailSent = await sendUpgradeDoneEmail(
result.maestroName, result.maestroName,
result.maestroEmail, result.maestroEmail,
result.registeredActivateStatus, result.registeredActivateStatus,
@@ -48,7 +48,7 @@ export const PATCH = withApiHandler<{ id: string }>(
result.requestedAccountType, result.requestedAccountType,
formatDate(result.availableActivateDateTime) formatDate(result.availableActivateDateTime)
); );
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime }); return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
} }
await rejectUpgradeRequest(maestroUpgradeID); await rejectUpgradeRequest(maestroUpgradeID);
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 97 KiB

+51 -1
View File
@@ -7,6 +7,32 @@ import { logger } from "@/lib/logger";
const AUTH_CTX = "auth"; const AUTH_CTX = "auth";
type AttemptRecord = { count: number; until: number };
const loginAttempts = new Map<string, AttemptRecord>();
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
function isLockedOut(adminName: string): boolean {
const record = loginAttempts.get(adminName);
if (!record || record.count < MAX_ATTEMPTS) return false;
if (record.until > Date.now()) return true;
loginAttempts.delete(adminName);
return false;
}
function recordFailure(adminName: string): void {
const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 };
record.count += 1;
if (record.count >= MAX_ATTEMPTS) {
record.until = Date.now() + LOCKOUT_MS;
}
loginAttempts.set(adminName, record);
}
function clearAttempts(adminName: string): void {
loginAttempts.delete(adminName);
}
const credentialsSchema = z.object({ const credentialsSchema = z.object({
adminName: z.string().trim().min(1), adminName: z.string().trim().min(1),
password: z.string().min(1), password: z.string().min(1),
@@ -42,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,
@@ -53,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({
@@ -67,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
@@ -81,10 +129,12 @@ export const {
const activateStatus = Number(admin?.ActivateStatus); const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
recordFailure(adminName);
logger.warn(AUTH_CTX, "login failed", { adminName }); logger.warn(AUTH_CTX, "login failed", { adminName });
return null; return null;
} }
clearAttempts(adminName);
logger.info(AUTH_CTX, "login success", { adminName, adminID }); logger.info(AUTH_CTX, "login success", { adminName, adminID });
return { return {
id: String(adminID), id: String(adminID),
@@ -108,19 +108,16 @@ function ExtensionRequestActions({
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED; const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) { if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>; return <span className="text-xs text-muted-foreground"> </span>;
} }
async function submitAction(action: "approve" | "cancel") { async function submitAction(action: "approve" | "cancel") {
const actionLabel = action === "approve" ? "승인" : "취소"; const actionLabel = action === "approve" ? "승인" : "취소";
const confirmed = window.confirm( if (!window.confirm(`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`)) {
`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return; return;
} }
@@ -136,14 +133,19 @@ function ExtensionRequestActions({
} }
); );
if (!response.ok) { const body = (await response.json().catch(() => null)) as {
const body = (await response.json().catch(() => null)) as { message?: string;
message?: string; emailSent?: boolean;
} | null; } | null;
if (!response.ok) {
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다."); throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
} }
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
}
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
}); });
@@ -158,27 +160,36 @@ function ExtensionRequestActions({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> {isRequested ? (
<Button <div className="flex items-center gap-2">
disabled={isPending} <Button
onClick={() => void submitAction("approve")} disabled={isPending}
size="sm" onClick={() => void submitAction("approve")}
type="button" size="sm"
> type="button"
<Check className="size-4" aria-hidden="true" /> >
<Check className="size-4" aria-hidden="true" />
</Button>
<Button </Button>
disabled={isPending} <Button
onClick={() => void submitAction("cancel")} disabled={isPending}
size="sm" onClick={() => void submitAction("cancel")}
type="button" size="sm"
variant="outline" type="button"
> variant="outline"
<X className="size-4" aria-hidden="true" /> >
<X className="size-4" aria-hidden="true" />
</Button>
</div> </Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? ( {errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p> <p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null} ) : null}
@@ -113,19 +113,16 @@ function UpgradeRequestActions({
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [emailWarning, setEmailWarning] = useState(false);
const isRequested = request.status === REQUEST_STATUSES.REQUESTED; const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) { if (!isRequested && !emailWarning) {
return <span className="text-xs text-muted-foreground"> </span>; return <span className="text-xs text-muted-foreground"> </span>;
} }
async function submitAction(action: "approve" | "reject") { async function submitAction(action: "approve" | "reject") {
const actionLabel = action === "approve" ? "승인" : "거절"; const actionLabel = action === "approve" ? "승인" : "거절";
const confirmed = window.confirm( if (!window.confirm(`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`)) {
`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return; return;
} }
@@ -141,14 +138,17 @@ function UpgradeRequestActions({
} }
); );
if (!response.ok) { const body = (await response.json().catch(() => null)) as {
const body = (await response.json().catch(() => null)) as { message?: string;
message?: string; emailSent?: boolean;
} | null; } | null;
throw new Error( if (!response.ok) {
body?.message ?? "업그레이드 신청 처리에 실패했습니다." throw new Error(body?.message ?? "업그레이드 신청 처리에 실패했습니다.");
); }
if (action === "approve" && body?.emailSent === false) {
setEmailWarning(true);
} }
startTransition(() => { startTransition(() => {
@@ -165,27 +165,36 @@ function UpgradeRequestActions({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> {isRequested ? (
<Button <div className="flex items-center gap-2">
disabled={isPending} <Button
onClick={() => void submitAction("approve")} disabled={isPending}
size="sm" onClick={() => void submitAction("approve")}
type="button" size="sm"
> type="button"
<Check className="size-4" aria-hidden="true" /> >
<Check className="size-4" aria-hidden="true" />
</Button>
<Button </Button>
disabled={isPending} <Button
onClick={() => void submitAction("reject")} disabled={isPending}
size="sm" onClick={() => void submitAction("reject")}
type="button" size="sm"
variant="outline" type="button"
> variant="outline"
<X className="size-4" aria-hidden="true" /> >
<X className="size-4" aria-hidden="true" />
</Button>
</div> </Button>
</div>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
{emailWarning ? (
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
</p>
) : null}
{errorMessage ? ( {errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p> <p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null} ) : null}
+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:
+99 -23
View File
@@ -7,7 +7,7 @@ Create environment files on the Synology NAS. Do not commit real `.env.*` files.
Stage file path: Stage file path:
```bash ```bash
/volume1/docker/service/jinaju/stage-chocoadmin/.env.stage /volume1/docker/service/jinaju/chocoadmin/.env.stage
``` ```
Stage example: Stage example:
@@ -16,23 +16,31 @@ Stage example:
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae" DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
AUTH_SECRET="replace-with-stage-secret" AUTH_SECRET="replace-with-stage-secret"
NEXTAUTH_SECRET="replace-with-stage-secret" NEXTAUTH_SECRET="replace-with-stage-secret"
AUTH_URL="https://stage-chocoadmin.jinaju.com" AUTH_URL="https://chocoadmin-stage.jisangs.com"
NEXTAUTH_URL="https://stage-chocoadmin.jinaju.com" NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
```
Production file path:
```bash
/volume1/docker/service/jinaju/chocoadmin/.env.production
``` ```
Production example: Production example:
```bash ```bash
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@AWS_EC2_HOST:3306/chocomae" 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"
AUTH_URL="https://NAS_HOST_OR_DOMAIN" AUTH_URL="https://chocoadmin.jinaju.com"
NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN" NEXTAUTH_URL="https://chocoadmin.jinaju.com"
``` ```
`AUTH_SECRET` and `NEXTAUTH_SECRET` should use the same strong random value per environment for Auth.js compatibility. `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.
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL. Auth.js then writes secure cookies, and `proxy.ts` reads the secure session cookie. `APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`chocoadmin-${APP_ENV}.session-token`), which keeps production and stage cookies separate even if they share a browser.
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
Generate a secret on the NAS: Generate a secret on the NAS:
@@ -42,14 +50,14 @@ openssl rand -base64 32
## 2. Synology Stage Deployment ## 2. Synology Stage Deployment
The stage source is deployed by pulling Git on the NAS: Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
```bash ```bash
cd /volume1/docker/service/jinaju/stage-chocoadmin cd /volume1/docker/service/jinaju/chocoadmin-stage
git pull --ff-only git pull --ff-only
``` ```
Stage uses `docker-compose.stage.yml`, the `stage-chocoadmin` container, and the external Docker network `proxy-network`. Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
Verify or create the network: Verify or create the network:
@@ -60,18 +68,25 @@ docker network inspect proxy-network >/dev/null 2>&1 || docker network create pr
Start or update stage: Start or update stage:
```bash ```bash
docker compose -f docker-compose.stage.yml down
docker compose -f docker-compose.stage.yml up -d --build 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 ps
docker compose -f docker-compose.stage.yml logs -f docker compose -f docker-compose.stage.yml logs -f
``` ```
If a service was renamed in the compose file, add `--remove-orphans` once to remove the stale container.
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. `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: Nginx Proxy Manager settings:
- Scheme: `http` - Scheme: `http`
- Forward Hostname / IP: `stage-chocoadmin` - Forward Hostname / IP: `chocoadmin-stage`
- Forward Port: `3000` - Forward Port: `3000`
- Websockets Support: enabled - Websockets Support: enabled
- SSL: enabled - SSL: enabled
@@ -80,7 +95,7 @@ Nginx Proxy Manager settings:
Stage URL: Stage URL:
```text ```text
https://stage-chocoadmin.jinaju.com https://chocoadmin-stage.jisangs.com
``` ```
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window. After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
@@ -97,7 +112,26 @@ docker compose ps
docker compose logs -f chocoadmin docker compose logs -f chocoadmin
``` ```
If production is also routed through Nginx Proxy Manager, attach it to `proxy-network` and use the container name and port `3000` as the upstream target. After redeployment, reload NPM:
```bash
docker exec npm nginx -s reload
```
Nginx Proxy Manager settings:
- Scheme: `http`
- Forward Hostname / IP: `chocoadmin`
- Forward Port: `3000`
- Websockets Support: enabled
- SSL: enabled
- Force SSL: enabled
Production URL:
```text
https://chocoadmin.jinaju.com
```
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from the compose `env_file`. 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`.
@@ -125,11 +159,11 @@ Before enabling approval/rejection operations against production DB:
## 6. Smoke Checks ## 6. Smoke Checks
After stage container start: After each deployment:
```bash ```bash
docker compose -f docker-compose.stage.yml ps docker compose -f docker-compose.stage.yml ps # stage
docker compose -f docker-compose.stage.yml logs -f docker compose ps # production
``` ```
Verify these routes in the browser: Verify these routes in the browser:
@@ -139,9 +173,51 @@ Verify these routes in the browser:
- `/extension-requests` - `/extension-requests`
- `/upgrade-requests` - `/upgrade-requests`
If the browser reports too many redirects after login: ## 7. Troubleshooting
1. Confirm `AUTH_URL` and `NEXTAUTH_URL` exactly match the public URL and scheme. ### ERR_TOO_MANY_REDIRECTS after login
2. Confirm Nginx Proxy Manager forwards to `stage-chocoadmin:3000` with scheme `http`.
3. Recreate the container after changing `.env.stage`. Middleware redirects to `/login`, login page redirects back to `/` — infinite loop. Work through this checklist in order:
4. Clear cookies for the stage domain.
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.
+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가 모바일에서 오른쪽 정렬 확인
- [ ] 데스크탑에서 기존 레이아웃 회귀 없음 확인
+4 -4
View File
@@ -70,7 +70,7 @@ pnpm db:check
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다. 스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
```bash ```bash
curl -I https://stage-chocoadmin.jinaju.com/maestros curl -I https://chocoadmin-stage.jisangs.com/maestros
``` ```
기대 결과: 기대 결과:
@@ -81,7 +81,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
브라우저에서 확인한다. 브라우저에서 확인한다.
- `https://stage-chocoadmin.jinaju.com/login` - `https://chocoadmin-stage.jisangs.com/login`
- `/maestros` - `/maestros`
- `/extension-requests` - `/extension-requests`
- `/upgrade-requests` - `/upgrade-requests`
@@ -119,7 +119,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
NAS에서 실행한다. NAS에서 실행한다.
```bash ```bash
cd /volume1/docker/service/jinaju/stage-chocoadmin cd /volume1/docker/service/jisangs/chocoadmin-stage
docker compose -f docker-compose.stage.yml restart docker compose -f docker-compose.stage.yml restart
docker compose -f docker-compose.stage.yml ps docker compose -f docker-compose.stage.yml ps
docker compose -f docker-compose.stage.yml logs --tail=100 docker compose -f docker-compose.stage.yml logs --tail=100
@@ -127,7 +127,7 @@ docker compose -f docker-compose.stage.yml logs --tail=100
기대 결과: 기대 결과:
- `stage-chocoadmin` 컨테이너가 `Up` 상태다. - `chocoadmin-stage` 컨테이너가 `Up` 상태다.
- 로그에 Next.js ready 메시지가 출력된다. - 로그에 Next.js ready 메시지가 출력된다.
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다. - 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
+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 위험은 낮음.
+119 -11
View File
@@ -159,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/
@@ -172,8 +178,11 @@ chocoadmin/
│ ├── maestros/ │ ├── maestros/
│ │ ├── route.ts # GET: 목록 │ │ ├── route.ts # GET: 목록
│ │ └── [id]/ │ │ └── [id]/
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정 │ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
│ │ ── students/route.ts # GET: 학생 목록 │ │ ── 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/
@@ -237,8 +246,15 @@ chocoadmin/
- 플레이어(학생) 전체 목록 - 플레이어(학생) 전체 목록
- 서버 사이드 페이지네이션 - 서버 사이드 페이지네이션
- 이름 검색 - 이름 검색
- 연장 신청 이력 - 연장 신청 이력 + [연장] 버튼
- 업그레이드 신청 이력 - 유료 요금제 계정에서만 버튼 활성화 (체험 계정 비활성)
- 클릭 시 현재 `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`)
@@ -375,9 +391,9 @@ 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. 검증 및 안정화
@@ -422,10 +438,10 @@ chocoadmin/
- [x] `docker compose logs -f` 확인 방법 - [x] `docker compose logs -f` 확인 방법
- [x] stage와 production의 권장 `.env` 로그 설정 예시 - [x] stage와 production의 권장 `.env` 로그 설정 예시
- [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차 - [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
- [ ] 검증 - [x] 검증
- [x] local에서 query문과 결과 전체가 출력되는지 확인 - [x] local에서 query문과 결과 전체가 출력되는지 확인
- [ ] stage에서 query문과 결과 전체가 출력되는지 확인 - [x] stage에서 query문과 결과 전체가 출력되는지 확인
- [ ] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인 - [x] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인
- [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략) - [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략)
### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화 ### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
@@ -498,6 +514,98 @@ chocoadmin/
- [ ] 업그레이드 승인 후 유료→유료 케이스: `{prev_price}원` 본문 확인 - [ ] 업그레이드 승인 후 유료→유료 케이스: `{prev_price}원` 본문 확인
- [x] SMTP 오류 시 승인 API 200 응답 + `error: send failed` 로그 확인 - [x] SMTP 오류 시 승인 API 200 응답 + `error: send failed` 로그 확인
### Phase 12. 관리자 직접 연장/업그레이드 신청 등록
> 마에스트로 상세 화면에서 관리자가 직접 연장·업그레이드 신청을 DB에 등록할 수 있도록 한다. 메일 발송은 하지 않는다.
- [x] 연장 신청 등록 API 구현 (`POST /api/maestros/[id]/extension-requests`)
- [x] 서버에서 `maestro.AccountType` 직접 조회 — 클라이언트 값 신뢰 안 함
- [x] 유료 요금제 계정이 아닌 경우 400 반환 (체험 계정 차단)
- [x] `maestro_extension` INSERT (`AccountType`, `RequestedDateTime`, `Status=1`)
- [x] 업그레이드 신청 등록 API 구현 (`POST /api/maestros/[id]/upgrade-requests`)
- [x] 트랜잭션 내에서 `maestro` SELECT → `maestro_upgrade` INSERT (원자성 보장)
- [x] `requestedAccountType > maestro.AccountType` 검증 (다운그레이드 차단, 400 반환)
- [x] `RegisteredActivateStatus`, `RegisteredAccountType` 서버 DB 조회값으로 저장
- [x] 연장 신청 이력 클라이언트 컴포넌트 (`ExtensionRequestsSection.tsx`)
- [x] 섹션 우상단 [연장] 버튼
- [x] 체험 계정 (`AccountType``PAID_ACCOUNT_TYPE_VALUES`) 시 버튼 비활성화
- [x] 등록 성공 후 `router.refresh()`로 이력 테이블 즉시 갱신
- [x] 업그레이드 신청 이력 클라이언트 컴포넌트 (`UpgradeRequestsSection.tsx`)
- [x] 섹션 우상단 요금제 콤보박스 + [업그레이드] 버튼
- [x] 현재 요금제 이하 옵션 `disabled`, 상위 요금제만 선택 가능
- [x] 선택 후 등록 성공 시 콤보박스 초기화 + `router.refresh()`
- [x] `PAID_ACCOUNT_TYPE_VALUES` 공용 상수로 추출 (`lib/constants.ts`)
- [x] 기존 `lib/maestros.ts`의 로컬 정의 제거 및 import로 교체
- [x] 두 신규 API route의 로컬 정의 제거 및 import로 교체
### Phase 13. 보안·정합성 수정
> AGENTS.md "Implemented phases" Phase 13 항목 참조.
- [x] 모든 항목 완료 (P0-1 ~ P2-8)
### Phase 14. 비밀번호 초기화 및 배포 스크립트
> 마에스트로 계정 비밀번호 초기화 기능을 추가한다.
> 완료 날짜: 2026-07-05
- [x] 비밀번호 초기화 API 구현 (`POST /api/maestros/[id]/reset-password`)
- [x] `resetMaestroPassword(maestroID)` 추가 (`lib/maestros.ts`)
- [x] `$transaction` 내에서 `UPDATE maestro SET Password = PASSWORD('123456')` 실행
- [x] `reset_maestro_password` 타입으로 `maestro_log` 기록
- [x] `withApiHandler`로 인증·에러 처리 공통화
- [x] 비밀번호 초기화 버튼 UI (`MaestroEditForm.tsx`)
- [x] `[암호 초기화]` 버튼을 `[저장]` 버튼 우측에 추가 (`variant="outline"`, `KeyRound` 아이콘)
- [x] `window.confirm`으로 `{마에스트로명} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?` 확인
- [x] 성공 시 기존 메시지 영역에 `암호 초기화 완료` 표시
### Phase 15. Production/Stage 배포 환경 안정화
> Production 첫 배포 과정에서 발견된 이슈들을 해결하고 NAS 배포 자동화 스크립트를 추가한다.
> 완료 날짜: 2026-07-05
- [x] Production 배포 경로 생성 및 git pull 방식 배포 설정
- Production: `/volume1/docker/service/jinaju/chocoadmin` + `docker-compose.yml`
- Stage: 동일 경로, `-f docker-compose.stage.yml` 로 구분
- [x] **포트 충돌 수정** (`docker-compose.yml`)
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"``expose: "3000"` 으로 변경
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
- `services: chocoadmin:``services: chocoadmin-stage:` 으로 변경
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
- `auth.ts`: `cookies.sessionToken.name = "chocoadmin-${APP_ENV}.session-token"` 추가
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
- 인라인 `"use server"` 클로저는 빌드마다 새 ID 부여 → 재배포 후 `Failed to find Server Action`
- 코드베이스 전체 인라인 서버 액션 검토 및 동일 패턴 수정 완료
- [x] Production/Stage 동시 운영 정상 확인
- [x] 배포 자동화 스크립트 추가 (`scripts/`)
- [x] `scripts/deploy-stage.sh` — git pull → proxy-network 확인 → `docker compose -f docker-compose.stage.yml up -d --build --remove-orphans` → NPM reload
- [x] `scripts/deploy-production.sh``yes` 입력 확인 후 동일 5단계 실행 (`docker-compose.yml` 사용)
- [x] 각 스크립트는 NAS 해당 디렉토리에서 직접 실행
### Phase 16. 반응형 웹 디자인
> 모바일 화면(360~430px)에서도 관리자 패널을 정상 사용할 수 있도록 사이드바 내비게이션, 표 overflow, 페이지네이션 정렬을 개선한다.
> 완료 날짜: 2026-07-07
- [x] 모바일 사이드바
- [x] `app/(admin)/nav-config.ts` 생성 — navItems 배열을 `layout.tsx``MobileNav.tsx`가 공유
- [x] `app/(admin)/MobileNav.tsx` 생성 — 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), 슬라이드인 패널(X 버튼), ESC 키 지원, pathname 변경 시 자동 닫힘, body 스크롤 잠금, CSS `translate-x` 트랜지션 애니메이션
- [x] `app/(admin)/layout.tsx` 수정 — 헤더에 `<MobileNav />` 통합; 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지
- [x] 표 overflow 수정 (`ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx`)
- 원인: 두 섹션은 서버 props로 데이터를 받아 SSR 초기 렌더에 `min-w-[560px]` 표가 포함됨 → `section``overflow` 미설정(`visible` 기본값)이어서 표가 섹션을 밀어냄 → `overflow-x-auto`가 확장된 너비를 그대로 가져가 스크롤 미발생. `StudentsSection`·`LogsSection`은 클라이언트 fetch 방식이라 SSR 초기 렌더에 표가 없어 동일 패턴으로도 정상 동작.
- [x] `<section>``overflow-hidden` 추가 — BFC 확립으로 섹션 너비를 뷰포트 기준으로 고정
- [x] 섹션 헤더를 `flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between`으로 변경 — 모바일에서 세로 스택, 액션 버튼 접근성 확보
- [x] 페이지네이션 오른쪽 정렬 — `<nav>``self-end sm:self-auto` 추가 (모바일 `flex-col`에서 오른쪽 정렬, 데스크탑 `flex-row`에서 기존 동작 유지)
- [x] `app/(admin)/extension-requests/page.tsx`
- [x] `app/(admin)/upgrade-requests/page.tsx`
- [x] `app/(admin)/maestros/page.tsx`
- [x] `app/(admin)/maestros/[maestroId]/StudentsSection.tsx`
- [x] `app/(admin)/maestros/[maestroId]/LogsSection.tsx`
--- ---
## 10. MVP 범위 ## 10. MVP 범위
+4 -1
View File
@@ -48,7 +48,10 @@ export function withApiHandler<P = Record<string, never>>(
error: String(error), error: String(error),
duration: Date.now() - t0, duration: Date.now() - t0,
}); });
throw error; return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
);
} }
}; };
} }
+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
+59 -26
View File
@@ -1,8 +1,10 @@
import { writeSync } from "fs";
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { format } from "sql-formatter"; import { format } from "sql-formatter";
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client"; import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
import { logger } from "@/lib/logger"; import { logger, timestamp } from "@/lib/logger";
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient; prisma?: PrismaClient;
@@ -10,6 +12,21 @@ const globalForPrisma = globalThis as unknown as {
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000"); 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) ──────────────────────────────────────────────── // ── SQL 로깅 ($on) ────────────────────────────────────────────────
function inlineParams(query: string, rawParams: string): string { function inlineParams(query: string, rawParams: string): string {
@@ -30,27 +47,29 @@ function inlineParams(query: string, rawParams: string): string {
function onQuery(e: Prisma.QueryEvent): void { function onQuery(e: Prisma.QueryEvent): void {
const isSlow = e.duration >= SLOW_QUERY_MS; const isSlow = e.duration >= SLOW_QUERY_MS;
const level = isSlow ? "warn" : "debug";
if (!logger.isEnabled(level)) return;
const isSensitive = e.query.includes("PASSWORD("); const isSensitive = e.query.includes("PASSWORD(");
const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params); const sqlWithParams = isSensitive ? e.query : inlineParams(e.query, e.params);
const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
const ts = new Date().toISOString();
const tag = level.toUpperCase().padEnd(5);
const slowLabel = isSlow ? " [SLOW]" : "";
const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
const indented = formatted
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
const out = `${header}\n${indented}`;
if (isSlow) { if (isSlow) {
console.error(out); const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 });
} else { const ts = timestamp();
console.log(out); 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`);
} }
} }
@@ -58,6 +77,12 @@ function onQuery(e: Prisma.QueryEvent): void {
const COL_MAX_WIDTH = 40; const COL_MAX_WIDTH = 40;
const SENSITIVE_KEYS = new Set(["Password", "password"]);
function safeValue(key: string, value: unknown): string {
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
}
function formatValue(v: unknown): string { function formatValue(v: unknown): string {
if (v === null || v === undefined) return "NULL"; if (v === null || v === undefined) return "NULL";
if (typeof v === "bigint") return v.toString(); if (typeof v === "bigint") return v.toString();
@@ -165,7 +190,7 @@ function buildResultLog(
if (typeof preview[0] === "object" && preview[0] !== null) { if (typeof preview[0] === "object" && preview[0] !== null) {
const objRows = preview as Record<string, unknown>[]; const objRows = preview as Record<string, unknown>[];
const headers = Object.keys(objRows[0]); const headers = Object.keys(objRows[0]);
const rows = objRows.map((row) => headers.map((h) => formatValue(row[h]))); const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
return `${label}${countLabel}\n${renderTable(headers, rows)}${moreLabel}`; return `${label}${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
} }
@@ -176,7 +201,7 @@ function buildResultLog(
if (result !== null && typeof result === "object") { if (result !== null && typeof result === "object") {
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [ const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
k, k,
formatValue(v), safeValue(k, v),
]); ]);
return `${label}\n${renderTable(["field", "value"], rows)}`; return `${label}\n${renderTable(["field", "value"], rows)}`;
} }
@@ -190,9 +215,9 @@ function logResult(
result: unknown result: unknown
): void { ): void {
if (!logger.isEnabled("debug")) return; if (!logger.isEnabled("debug")) return;
const ts = new Date().toISOString(); const ts = timestamp();
const body = buildResultLog(model, operation, result); const body = buildResultLog(model, operation, result);
console.log(`[${ts}] [DEBUG] [db:result] ${body}`); writeAll(1, `[${ts}] [DEBUG] [db:result] ${body}\n`);
} }
// ── PrismaClient 생성 ───────────────────────────────────────────── // ── PrismaClient 생성 ─────────────────────────────────────────────
@@ -215,9 +240,17 @@ function createPrismaClient() {
const extendedClient = baseClient.$extends({ const extendedClient = baseClient.$extends({
query: { query: {
$allOperations: async ({ model, operation, args, query }) => { $allOperations: async ({ model, operation, args, query }) => {
const result = await query(args); try {
logResult(model, operation, result); const result = await query(args);
return result; 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;
}
}, },
}, },
}); });
+123 -43
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { REQUEST_STATUSES } from "@/lib/constants"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateExtendedAvailableDate, calculateExtendedAvailableDate,
@@ -22,7 +22,7 @@ const extensionRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -53,7 +53,7 @@ export type ExtensionRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export async function getExtensionRequests( export async function getExtensionRequests(
@@ -114,45 +114,127 @@ export async function approveExtensionRequest(
maestroEmail: string; maestroEmail: string;
}> { }> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_extension.updateMany({
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_extension.count({
where: { MaestroExtensionID: maestroExtensionID },
});
throw new ApiError(
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
select: {
MaestroID: true,
AccountType: true,
maestro: {
select: {
Name: true,
Email: true,
AvailableActivateDateTime: true,
ActivateStatus: true,
},
},
},
});
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
}
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
request.maestro.AvailableActivateDateTime request!.maestro.AvailableActivateDateTime
); );
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
ActivateStatus: 2, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_extension.updateMany({ await tx.maestro_extension.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "extension_maestro", Type: "extension_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildExtensionLogRemark( Remark: buildExtensionLogRemark(
request.maestro.Name, request!.maestro.Name,
request.AccountType request!.AccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(), maestroName: request!.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(), maestroEmail: request!.maestro.Email.trim(),
};
});
}
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,
}; };
}); });
} }
@@ -161,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
), ),
}, },
}); });
@@ -189,25 +288,6 @@ export function parseExtensionRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroExtensionID: number
) {
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
include: { maestro: true },
});
if (!request) {
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
}
return request;
}
function buildExtensionRequestWhere( function buildExtensionRequestWhere(
params: ExtensionRequestSearchParams params: ExtensionRequestSearchParams
+16 -1
View File
@@ -18,6 +18,21 @@ function shouldLog(level: Level): boolean {
return PRIORITY[level] >= PRIORITY[getMinLevel()]; 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( function write(
level: Level, level: Level,
ctx: string, ctx: string,
@@ -25,7 +40,7 @@ function write(
data?: Record<string, unknown> data?: Record<string, unknown>
): void { ): void {
if (!shouldLog(level)) return; if (!shouldLog(level)) return;
const ts = new Date().toISOString(); const ts = timestamp();
const tag = level.toUpperCase().padEnd(5); const tag = level.toUpperCase().padEnd(5);
const line = data const line = data
? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}` ? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}`
+123 -55
View File
@@ -3,8 +3,9 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, ACCOUNT_TYPES } from "@/lib/constants"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { formatDate } from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const; const pageSizeOptions = [10, 20, 50, 100] as const;
const sortOptions = [ const sortOptions = [
@@ -74,7 +75,6 @@ export type MaestroDetail = {
players: number; players: number;
extensionRequests: number; extensionRequests: number;
upgradeRequests: number; upgradeRequests: number;
logs: number;
}; };
players: Array<{ players: Array<{
playerID: number; playerID: number;
@@ -96,22 +96,8 @@ export type MaestroDetail = {
requestedDateTime: string; requestedDateTime: string;
status: number; status: number;
}>; }>;
logs: Array<{
maestroLogID: number;
type: string;
logDateTime: string;
remark: string;
}>;
}; };
const paidAccountTypeValues = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
const activateStatusValues = [ const activateStatusValues = [
ACTIVATE_STATUSES.PENDING, ACTIVATE_STATUSES.PENDING,
ACTIVATE_STATUSES.TRIAL, ACTIVATE_STATUSES.TRIAL,
@@ -130,7 +116,7 @@ export const updateMaestroSchema = z.object({
accountType: z.coerce accountType: z.coerce
.number() .number()
.int() .int()
.refine((v) => (paidAccountTypeValues as readonly number[]).includes(v)) .refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v))
.optional(), .optional(),
availableActivateDateTime: z.string().min(1).optional(), availableActivateDateTime: z.string().min(1).optional(),
allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(), allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(),
@@ -214,23 +200,21 @@ export async function updateMaestro(
} }
if (payload.availableActivateDateTime !== undefined) { if (payload.availableActivateDateTime !== undefined) {
const newDate = new Date(payload.availableActivateDateTime); const newDate = new Date(payload.availableActivateDateTime);
if (!isNaN(newDate.getTime())) { if (isNaN(newDate.getTime())) {
const prevMin = Math.floor( throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
maestro.AvailableActivateDateTime.getTime() / 60000 }
); const prevMin = Math.floor(
const newMin = Math.floor(newDate.getTime() / 60000); maestro.AvailableActivateDateTime.getTime() / 60000
if (prevMin !== newMin) { );
updates.AvailableActivateDateTime = newDate; const newMin = Math.floor(newDate.getTime() / 60000);
const prevStr = maestro.AvailableActivateDateTime.toISOString().slice( if (prevMin !== newMin) {
0, updates.AvailableActivateDateTime = newDate;
10 const prevStr = formatDate(maestro.AvailableActivateDateTime);
); const newStr = formatDate(newDate);
const newStr = newDate.toISOString().slice(0, 10); logs.push({
logs.push({ type: "update_maestro_available_date",
type: "update_maestro_available_date", remark: `${prevStr} -> ${newStr}`,
remark: `${prevStr} -> ${newStr}`, });
});
}
} }
} }
if ( if (
@@ -271,6 +255,111 @@ export async function updateMaestro(
return { changed: true }; return { changed: true };
} }
export async function resetMaestroPassword(
maestroID: number
): Promise<boolean> {
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestro) return false;
await db.$transaction(async (tx) => {
await tx.$executeRaw`UPDATE maestro SET Password = PASSWORD('123456') WHERE MaestroID = ${maestroID}`;
await tx.maestro_log.create({
data: {
Type: "reset_maestro_password",
MaestroID: maestroID,
LogDateTime: new Date(),
Remark: "admin reset to 123456",
},
});
});
return true;
}
const logPageSizeOptions = [10, 20, 50] as const;
type LogPageSize = (typeof logPageSizeOptions)[number];
const logSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((v) => logPageSizeOptions.includes(v as LogPageSize))
.catch(10),
});
export type MaestroLogsResult = {
items: Array<{
maestroLogID: number;
type: string;
logDateTime: string;
remark: string;
}>;
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
};
export async function getMaestroLogs(
maestroID: number,
rawSearchParams: RawSearchParams = {}
): Promise<MaestroLogsResult | null> {
const t0 = Date.now();
const maestroExists = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: { MaestroID: true },
});
if (!maestroExists) return null;
const params = logSearchSchema.parse(searchParamsToRecord(rawSearchParams));
const skip = (params.page - 1) * params.pageSize;
const [totalCount, logs] = await Promise.all([
db.maestro_log.count({ where: { MaestroID: maestroID } }),
db.maestro_log.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroLogID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroLogID: true,
Type: true,
LogDateTime: true,
Remark: true,
},
}),
]);
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
logger.debug("maestros/[id]/logs", "fetched", {
id: maestroID,
page: params.page,
totalCount,
duration: Date.now() - t0,
});
return {
items: logs.map((log) => ({
maestroLogID: log.MaestroLogID,
type: log.Type.trim(),
logDateTime: log.LogDateTime.toISOString(),
remark: log.Remark.trim(),
})),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages,
};
}
const studentPageSizeOptions = [10, 20, 50] as const; const studentPageSizeOptions = [10, 20, 50] as const;
type StudentPageSize = (typeof studentPageSizeOptions)[number]; type StudentPageSize = (typeof studentPageSizeOptions)[number];
@@ -439,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" },
@@ -484,17 +570,6 @@ export async function getMaestroDetail(
Status: true, Status: true,
}, },
}), }),
db.maestro_log.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroLogID: "desc" },
take: 30,
select: {
MaestroLogID: true,
Type: true,
LogDateTime: true,
Remark: true,
},
}),
]); ]);
logger.info("maestros/[id]", "fetched", { logger.info("maestros/[id]", "fetched", {
@@ -512,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,
@@ -534,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(),
})),
}; };
} }
+145 -52
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants"; import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateUpgradePrice, calculateUpgradePrice,
@@ -25,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({
q: z.string().trim().catch(""), q: z.string().trim().catch(""),
status: z status: z
.union([z.literal("all"), z.coerce.number().int().min(0)]) .union([z.literal("all"), z.coerce.number().int().min(0)])
.optional() .default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED), .catch(REQUEST_STATUSES.REQUESTED),
}); });
@@ -57,7 +57,7 @@ export type UpgradeRequestListResult = {
totalCount: number; totalCount: number;
totalPages: number; totalPages: number;
q: string; q: string;
status?: number | "all"; status: number | "all";
}; };
export async function getUpgradeRequests( export async function getUpgradeRequests(
@@ -121,48 +121,148 @@ export async function approveUpgradeRequest(
requestedAccountType: number; requestedAccountType: number;
}> { }> {
return db.$transaction(async (tx) => { return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID); // Atomically claim the request — prevents concurrent double-approval
const claimed = await tx.maestro_upgrade.updateMany({
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
data: { Status: REQUEST_STATUSES.APPLIED },
});
if (claimed.count === 0) {
const exists = await tx.maestro_upgrade.count({
where: { MaestroUpgradeID: maestroUpgradeID },
});
throw new ApiError(
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
exists ? 409 : 404
);
}
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
select: {
MaestroID: true,
RequestedAccountType: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
maestro: { select: { Name: true, Email: true, AccountType: true, ActivateStatus: true } },
},
});
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
const isStillValidUpgrade = isApprovalTrial
? request!.RequestedAccountType >= request!.maestro.AccountType
: request!.RequestedAccountType > request!.maestro.AccountType;
if (!isStillValidUpgrade) {
throw new ApiError(
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
409
);
}
const availableActivateDateTime = calculateUpgradeAvailableDate(); const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({ await tx.maestro.update({
where: { MaestroID: request.MaestroID }, where: { MaestroID: request!.MaestroID },
data: { data: {
AccountType: request.RequestedAccountType, AccountType: request!.RequestedAccountType,
ActivateStatus: ACTIVATE_STATUSES.ACTIVE, ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime, AvailableActivateDateTime: availableActivateDateTime,
}, },
}); });
await tx.maestro_upgrade.updateMany({ await tx.maestro_upgrade.updateMany({
where: { where: {
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
Status: REQUEST_STATUSES.REQUESTED, Status: REQUEST_STATUSES.REQUESTED,
}, },
data: { Status: REQUEST_STATUSES.CLOSED }, data: { Status: REQUEST_STATUSES.CLOSED },
}); });
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({ await tx.maestro_log.create({
data: { data: {
Type: "upgrade_maestro", Type: "upgrade_maestro",
MaestroID: request.MaestroID, MaestroID: request!.MaestroID,
LogDateTime: new Date(), LogDateTime: new Date(),
Remark: buildUpgradeLogRemark( Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus, request!.RegisteredActivateStatus,
request.RegisteredAccountType, request!.RegisteredAccountType,
request.RequestedAccountType request!.RequestedAccountType
), ),
}, },
}); });
return { return {
availableActivateDateTime: availableActivateDateTime.toISOString(), availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(), maestroName: request!.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(), maestroEmail: request!.maestro.Email.trim(),
registeredActivateStatus: request.RegisteredActivateStatus, registeredActivateStatus: request!.RegisteredActivateStatus,
registeredAccountType: request!.RegisteredAccountType,
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, registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType, requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
}; };
}); });
} }
@@ -171,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
), ),
}, },
}); });
@@ -200,31 +318,6 @@ export function parseUpgradeRequestSearchParams(
); );
} }
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroUpgradeID: number
) {
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } },
});
if (!request) {
throw new ApiError(
"업그레이드 신청을 찾을 수 없습니다.",
404
);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ApiError(
"이미 처리된 업그레이드 신청입니다.",
409
);
}
return request;
}
function buildUpgradeRequestWhere( function buildUpgradeRequestWhere(
params: UpgradeRequestSearchParams params: UpgradeRequestSearchParams
+2
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) {
+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"