Compare commits
59 Commits
5dd7375aad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d7659812 | |||
| e5c4d325bf | |||
| fb0861f211 | |||
| a6fcff98b4 | |||
| bbadaaea36 | |||
| bd921528f4 | |||
| e6882b158a | |||
| c7d55b5463 | |||
| 5463395b04 | |||
| b9219da6a4 | |||
| a213699c02 | |||
| ff67e5cf5a | |||
| a15543859e | |||
| b9aa6e07a9 | |||
| c635d64141 | |||
| fcab1c83b7 | |||
| 65e9b0c5f3 | |||
| c2b1b6169a | |||
| df1cdd3304 | |||
| 0330a99f5f | |||
| b9db9ff3e7 | |||
| 080640aa50 | |||
| c6976d7648 | |||
| 802ddb0fa7 | |||
| 8017d23717 | |||
| 8269eee996 | |||
| 7e2f629681 | |||
| f6e0e845f4 | |||
| a0d371837c | |||
| 56f32f8045 | |||
| 0a9aee58e0 | |||
| 2231ac4bfe | |||
| 41a246ad7b | |||
| 3c5848cdc5 | |||
| 854a3e2bfb | |||
| 2d9b8d0624 | |||
| 7669e9d0de | |||
| b2483a1b77 | |||
| 5f9bb39ce1 | |||
| f384388c67 | |||
| bcd0ef2af3 | |||
| b28e88db66 | |||
| 89c0e8be84 | |||
| a8ee01ece9 | |||
| 04b2a925bb | |||
| 0481ffb1a8 | |||
| 0a7361402d | |||
| 39c5ddb55c | |||
| 920176d57d | |||
| 1f6da75907 | |||
| d82f39c2f6 | |||
| 976fe42a2a | |||
| 605f6bcdf0 | |||
| 5fa387fa96 | |||
| fbe4caa9e1 | |||
| dbf9b3e180 | |||
| a93a6fa8e1 | |||
| af5b46baf3 | |||
| c6a5418d25 |
@@ -0,0 +1,21 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
coverage
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
docs
|
||||
@@ -0,0 +1,58 @@
|
||||
# Local development
|
||||
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
|
||||
AUTH_SECRET="replace-with-local-secret"
|
||||
NEXTAUTH_SECRET="replace-with-local-secret"
|
||||
AUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
# Logging: APP_ENV=local|stage|production / LOG_LEVEL=debug|info|warn|error
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=debug
|
||||
# Mail (MAIL_SEND_ENABLED=false 로 발송 비활성화, true 로 실제 발송)
|
||||
MAIL_SMTP_HOST=smtp.gmail.com
|
||||
MAIL_SMTP_PORT=465
|
||||
MAIL_SMTP_SECURE=true
|
||||
MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
MAIL_FROM_NAME=초코마에
|
||||
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
MAIL_SEND_ENABLED=false
|
||||
MAIL_OVERRIDE_TO=jisangs@naver.com
|
||||
|
||||
# Production example (.env.production)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
|
||||
# AUTH_SECRET="replace-with-production-secret"
|
||||
# NEXTAUTH_SECRET="replace-with-production-secret"
|
||||
# AUTH_URL="https://NAS_HOST_OR_DOMAIN"
|
||||
# NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN"
|
||||
# APP_ENV=production
|
||||
# LOG_LEVEL=info
|
||||
# MAIL_SMTP_HOST=smtp.gmail.com
|
||||
# MAIL_SMTP_PORT=465
|
||||
# MAIL_SMTP_SECURE=true
|
||||
# MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
# MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
# MAIL_FROM_NAME=초코마에
|
||||
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
# MAIL_SEND_ENABLED=true
|
||||
# MAIL_OVERRIDE_TO=
|
||||
|
||||
# Stage example (.env.stage)
|
||||
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
|
||||
# AUTH_SECRET="replace-with-stage-secret"
|
||||
# NEXTAUTH_SECRET="replace-with-stage-secret"
|
||||
# AUTH_URL="https://STAGE_HOST_OR_DOMAIN"
|
||||
# NEXTAUTH_URL="https://STAGE_HOST_OR_DOMAIN"
|
||||
# APP_ENV=stage
|
||||
# LOG_LEVEL=debug
|
||||
# MAIL_SMTP_HOST=smtp.gmail.com
|
||||
# MAIL_SMTP_PORT=465
|
||||
# MAIL_SMTP_SECURE=true
|
||||
# MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
# MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
# MAIL_FROM_NAME=초코마에
|
||||
# MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
# MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
# MAIL_SEND_ENABLED=false
|
||||
# MAIL_OVERRIDE_TO=jisangs@naver.com
|
||||
+42
-22
@@ -1,25 +1,45 @@
|
||||
### macOS
|
||||
# Finder metadata
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Custom folder icons
|
||||
Icon
|
||||
|
||||
|
||||
# Volume root files
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
### Local development tools
|
||||
*.pem
|
||||
.idea/
|
||||
|
||||
|
||||
# Claude Code local settings
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/lib/generated/prisma
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.next
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
lib/generated/prisma
|
||||
docs
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
|
||||
---
|
||||
|
||||
# Project: chocoadmin
|
||||
|
||||
Admin panel for the chocomae service (mouse-typing), extracted into a standalone Next.js 16 app. It shares the existing MariaDB database — **do not modify the DB schema**.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
|
||||
- **Race-condition guard for approve/cancel/reject**: use a conditional `updateMany({ where: { id, Status: REQUEST_STATUSES.REQUESTED }, data: { Status: <target> } })` as the first step inside a transaction. If `count === 0`, check existence to return 404 vs 409. Never use read-then-write (findUnique → check Status → update) — it allows double-processing under concurrent requests.
|
||||
- **Wrap every mutation in a transaction**: `maestro`, `maestro_extension`/`maestro_upgrade`, and `maestro_log` must be updated atomically.
|
||||
- **Send email only after a successful commit**, never inside the transaction.
|
||||
- **Never use `include: { maestro: true }`** when querying `maestro_extension` or `maestro_upgrade`. Always use `select` with only the needed columns to avoid logging the maestro `Password` field.
|
||||
- Admin password verification uses MariaDB `PASSWORD()` function — the existing `admin` table stores hashed passwords this way. Do not change this without a migration plan.
|
||||
- Extension approval date calculation happens **server-side**: if current expiry is in the past, use today +1 year; otherwise use current expiry +1 year.
|
||||
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
|
||||
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
|
||||
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
|
||||
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage` — **never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
|
||||
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
|
||||
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
|
||||
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
|
||||
- **Upgrade requests**: TRIAL accounts (`ActivateStatus = 1`) may request the **same tier** (trial-to-paid conversion, `requestedAccountType >= AccountType`). ACTIVE accounts must use a strictly higher tier (`>`). At approval time, this direction is re-verified against the maestro's **current** `AccountType` — if it became invalid (e.g., admin manually raised the tier), the approval throws 409 and rolls back.
|
||||
- Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional.
|
||||
|
||||
## Authentication
|
||||
|
||||
- NextAuth.js v5 (`next-auth@5.0.0-beta.31`) with Credentials Provider.
|
||||
- Config lives in `auth.ts` (project root), not inside `app/`.
|
||||
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
|
||||
- API routes must call `auth()` and return 401 if no session.
|
||||
- Session `maxAge` is 8 hours — do not lengthen without review.
|
||||
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
|
||||
- Session cookie name `chocoadmin-${APP_ENV}.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly.
|
||||
- Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop.
|
||||
|
||||
## Key files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) |
|
||||
| `proxy.ts` | Middleware — redirects unauthenticated users to /login |
|
||||
| `lib/db.ts` | Prisma Client singleton with SQL pretty-logging and result table logging |
|
||||
| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` |
|
||||
| `lib/errors.ts` | `ApiError` — unified error class with HTTP status code |
|
||||
| `lib/api-handler.ts` | `withApiHandler` — wraps route handlers with auth, timing, and error logging |
|
||||
| `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`); `PAID_ACCOUNT_TYPE_VALUES` (유료 계정 유형 배열 — 중복 정의 방지용 공용 상수) |
|
||||
| `lib/maestros.ts` | Maestro DB queries |
|
||||
| `lib/extension-requests.ts` | Extension request queries and approval logic |
|
||||
| `lib/upgrade-requests.ts` | Upgrade request queries and approval logic |
|
||||
| `prisma/schema.prisma` | Generated via `prisma db pull` — do not hand-edit model fields |
|
||||
| `docs/business-rules.md` | Full reference for existing chocomae business logic |
|
||||
| `docs/phase/phase9.md` | Log policy: levels, format, masking rules, Docker log operations |
|
||||
|
||||
## maestro_log conventions
|
||||
|
||||
Always record a log entry after a successful DB change. Use these `Type` values:
|
||||
|
||||
| Type | When |
|
||||
|---|---|
|
||||
| `extension_maestro` | Extension approved |
|
||||
| `cancel_extension_maestro` | Extension cancelled (new type, not in original) |
|
||||
| `upgrade_maestro` | Upgrade approved |
|
||||
| `reject_upgrade_maestro` | Upgrade rejected (new type, not in original) |
|
||||
| `update_maestro_name` | Maestro name changed | `{prev} -> {new}` |
|
||||
| `update_maestro_email` | Maestro email changed | `{prev} -> {new}` |
|
||||
| `update_maestro_status` | ActivateStatus changed (admin, new) | `{prev} -> {new}` (numeric) |
|
||||
| `update_maestro_account_type` | AccountType changed (admin, new) | `{prev} -> {new}` (numeric) |
|
||||
| `update_maestro_available_date` | AvailableActivateDateTime changed (admin, new) | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
|
||||
| `update_maestro_allow_enter_code` | AllowEditEnterCode changed (admin, new) | `{prev} -> {new}` (0 or 1) |
|
||||
| `request_extension_maestro` | Admin-initiated extension request created | `{maestroName}({accountTypeLabel})` |
|
||||
| `request_upgrade_maestro` | Admin-initiated upgrade request created | `{registeredAccountTypeLabel} -> {requestedAccountTypeLabel}` |
|
||||
| `reset_maestro_password` | Maestro password reset to 123456 by admin | `admin reset to 123456` |
|
||||
|
||||
`Remark` column is `char(100)` — keep values under 100 characters.
|
||||
|
||||
## Implemented phases (do not re-implement)
|
||||
|
||||
- Phase 0–8: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
|
||||
- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification.
|
||||
- Phase 10: Maestro info edit form (`PATCH /api/maestros/[id]`) + student list with server-side pagination (`GET /api/maestros/[id]/students`).
|
||||
- Phase 11: Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`). Upgrade email edge-case verification (trial→paid, paid→paid) still pending.
|
||||
- Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page.
|
||||
- `POST /api/maestros/[id]/extension-requests` — inserts into `maestro_extension` using maestro's current `AccountType` read server-side; rejects trial account types.
|
||||
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
|
||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
|
||||
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
|
||||
- Phase 15:
|
||||
- Password reset button (`암호 초기화`) added to `MaestroEditForm.tsx` — calls `POST /api/maestros/[id]/reset-password`, shows `window.confirm` with maestro name, displays inline success/error message.
|
||||
- `resetMaestroPassword(maestroID)` added to `lib/maestros.ts` — runs `UPDATE maestro SET Password = PASSWORD('123456')` inside a `$transaction` with a `reset_maestro_password` log entry.
|
||||
- `app/api/maestros/[id]/reset-password/route.ts` — `POST` handler wrapped with `withApiHandler`.
|
||||
- Deployment shell scripts added: `scripts/deploy-stage.sh` and `scripts/deploy-production.sh` — each runs git pull, ensures proxy-network, `docker compose up -d --build --remove-orphans`, and `docker exec npm nginx -s reload`. Production script requires `yes` confirmation.
|
||||
- Phase 13 (security/correctness fixes):
|
||||
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
|
||||
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
|
||||
- P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency.
|
||||
- P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade.
|
||||
- P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time.
|
||||
- P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries.
|
||||
- P1-6: Extension/upgrade approval routes now `await` email send and include `emailSent: boolean` in response; table UI shows inline amber warning when `emailSent === false`.
|
||||
- P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed.
|
||||
- P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses).
|
||||
- P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`).
|
||||
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
|
||||
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
|
||||
|
||||
- Phase 16: 반응형 웹 디자인 (Responsive Web Design)
|
||||
- `app/(admin)/nav-config.ts` — navItems 배열 공유 설정 파일 (`layout.tsx`·`MobileNav.tsx` 공용)
|
||||
- `app/(admin)/MobileNav.tsx` — 모바일 슬라이드인 사이드바 클라이언트 컴포넌트. 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), X 버튼, ESC 키, pathname 변경 시 자동 닫힘, body 스크롤 잠금 포함. CSS `translate-x` 트랜지션으로 애니메이션.
|
||||
- `app/(admin)/layout.tsx` — 헤더에 `<MobileNav />` 통합. 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지.
|
||||
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — `<section>`에 `overflow-hidden` 추가. SSR props로 표가 초기 렌더에 포함되어 `min-w-[560px]`가 섹션을 밀어내던 문제 수정. 헤더를 `flex-col sm:flex-row`로 변경해 모바일에서 액션 버튼 접근성 확보.
|
||||
- 페이지네이션 `<nav>` 5개 파일(`extension-requests/page.tsx`, `upgrade-requests/page.tsx`, `maestros/page.tsx`, `StudentsSection.tsx`, `LogsSection.tsx`) — `self-end sm:self-auto` 추가로 모바일에서 오른쪽 정렬.
|
||||
|
||||
## Planned phases (not yet implemented)
|
||||
|
||||
이후 작업 후보는 `docs/plan/project-plan.md` Section 10 "MVP 이후 확장 후보" 참조.
|
||||
|
||||
## Packages to know
|
||||
|
||||
- `mariadb` + `@prisma/adapter-mariadb` — Prisma uses the mariadb driver directly (not mysql2)
|
||||
- `@base-ui/react` — used instead of Radix UI primitives for shadcn components
|
||||
- `zod` v4 — Zod v4 API differs from v3; check docs before writing schemas
|
||||
- `next-auth` v5 beta — API surface differs significantly from v4
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV DATABASE_URL="mysql://build:build@127.0.0.1:3306/chocomae"
|
||||
ENV AUTH_SECRET="build-time-placeholder"
|
||||
ENV NEXTAUTH_SECRET="build-time-placeholder"
|
||||
ENV NEXTAUTH_URL="http://localhost:3000"
|
||||
RUN pnpm prisma generate
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,162 @@
|
||||
# chocoadmin
|
||||
|
||||
chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스로 분리한 웹 애플리케이션이다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|---|---|
|
||||
| 프레임워크 | Next.js 16 (App Router) |
|
||||
| 언어 | TypeScript |
|
||||
| 패키지 매니저 | pnpm |
|
||||
| UI / CSS | Tailwind CSS v4 + shadcn/ui (Base UI) |
|
||||
| Table | TanStack Table v8 |
|
||||
| ORM | Prisma v7 (MariaDB adapter) |
|
||||
| 인증 | NextAuth.js v5 (admin 테이블 기반) |
|
||||
| 유효성 검증 | Zod v4 |
|
||||
| 이메일 | Nodemailer |
|
||||
| DB | MariaDB 10.11 (기존 chocomae DB 공유) |
|
||||
|
||||
## 구현된 기능
|
||||
|
||||
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환, 로그인 실패 5회 → 15분 잠금)
|
||||
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
|
||||
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
|
||||
- 마에스트로 정보 수정 — 이름, 이메일, 상태, 계정유형, 만료일, 엔터코드 허용 여부 (`PATCH /api/maestros/[id]`)
|
||||
- 마에스트로 암호 초기화 — 관리자가 `123456`으로 초기화 (트랜잭션 + `maestro_log`)
|
||||
- 수강생 목록 — 서버사이드 페이지네이션 (`GET /api/maestros/[id]/students`)
|
||||
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log` + 이메일 발송)
|
||||
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log` + 이메일 발송)
|
||||
- 관리자 주도 연장/업그레이드 신청 등록 — 마에스트로 상세 화면에서 직접 등록 (이메일 미발송)
|
||||
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
|
||||
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 컬럼 마스킹 (`Password`)
|
||||
- 동시 승인 방지 — 연장/업그레이드 승인·취소·거절 시 조건부 원자 UPDATE로 이중 처리 차단
|
||||
- 이메일 자동 발송 — 연장/업그레이드 승인 후 Nodemailer로 발송, 실패 시 UI 경고 표시
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
### 1. 의존성 설치
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 환경변수 설정
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
`.env.local`을 열어 실제 값으로 교체한다.
|
||||
|
||||
```env
|
||||
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
|
||||
AUTH_SECRET="..."
|
||||
NEXTAUTH_SECRET="..."
|
||||
AUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
```
|
||||
|
||||
### 3. DB 연결 확인
|
||||
|
||||
```bash
|
||||
pnpm db:check
|
||||
```
|
||||
|
||||
### 4. 개발 서버 실행
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
[http://localhost:3000](http://localhost:3000) 에서 확인한다.
|
||||
|
||||
## 환경변수 전체 목록
|
||||
|
||||
| 변수 | 필수 | 설명 |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | ✅ | MariaDB 연결 문자열 (`mysql://...`) |
|
||||
| `AUTH_SECRET` | ✅ | NextAuth 세션 암호화 키 |
|
||||
| `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) |
|
||||
| `AUTH_URL` | ✅ | 서비스 기본 URL |
|
||||
| `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) |
|
||||
| `APP_ENV` | — | `local` \| `stage` \| `production` — 미설정 시 `debug` 레벨 기본값 |
|
||||
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` — production 권장: `info` |
|
||||
| `DB_SLOW_QUERY_MS` | — | slow query 임계값(ms), 기본 `1000` — 초과 시 `warn` 로그 |
|
||||
| `MAIL_FROM_NAME` | — | 발송자 이름 (Phase 11) |
|
||||
| `MAIL_FROM_ADDRESS` | — | 발송자 주소 (Phase 11) |
|
||||
| `MAIL_BCC_ADDRESS` | — | BCC 주소 (Phase 11) |
|
||||
| `MAIL_SMTP_HOST` | — | SMTP 호스트 (Phase 11) |
|
||||
| `MAIL_SMTP_PORT` | — | SMTP 포트 (Phase 11) |
|
||||
| `MAIL_SMTP_SECURE` | — | TLS 여부 (Phase 11) |
|
||||
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
|
||||
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
|
||||
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
|
||||
| `MAIL_OVERRIDE_TO` | — | 설정 시 모든 메일 수신인을 이 주소로 고정 — local/stage 테스트용 (Phase 11) |
|
||||
|
||||
## 배포 (Synology NAS Docker)
|
||||
|
||||
```bash
|
||||
# 스테이지 배포
|
||||
bash scripts/deploy-stage.sh
|
||||
|
||||
# 운영 배포 (yes 입력 확인 필요)
|
||||
bash scripts/deploy-production.sh
|
||||
```
|
||||
|
||||
각 스크립트는 `git pull` → `proxy-network` 확인 → `docker compose up -d --build --remove-orphans` → `docker exec npm nginx -s reload` 순으로 실행된다.
|
||||
|
||||
`docker-compose.yml`의 `environment` 섹션에 위 환경변수를 설정한다. `TZ: Asia/Seoul`은 두 compose 파일에 이미 포함되어 있다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
chocoadmin/
|
||||
├── app/
|
||||
│ ├── (auth)/login/ # 관리자 로그인
|
||||
│ ├── (admin)/ # 인증 필요 화면
|
||||
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
|
||||
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
|
||||
│ │ ├── maestros/ # 마에스트로 목록·상세
|
||||
│ │ ├── extension-requests/
|
||||
│ │ └── upgrade-requests/
|
||||
│ └── api/ # API routes
|
||||
│ └── maestros/[id]/
|
||||
│ ├── route.ts # PATCH — 마에스트로 정보 수정
|
||||
│ ├── students/route.ts # GET — 수강생 목록 (페이지네이션)
|
||||
│ ├── extension-requests/ # POST — 관리자 주도 연장 신청
|
||||
│ ├── upgrade-requests/ # POST — 관리자 주도 업그레이드 신청
|
||||
│ └── reset-password/ # POST — 암호 초기화
|
||||
├── components/
|
||||
│ ├── data-table/ # TanStack Table 기반 테이블
|
||||
│ ├── forms/
|
||||
│ └── ui/ # shadcn/ui 컴포넌트
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma Client 싱글톤 (SQL/결과 로깅 포함)
|
||||
│ ├── logger.ts # 공통 로거 (debug/info/warn/error)
|
||||
│ ├── errors.ts # ApiError 공통 에러 클래스
|
||||
│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리)
|
||||
│ ├── mail.ts # Nodemailer 이메일 발송
|
||||
│ ├── constants.ts # 상태값 상수
|
||||
│ ├── maestros.ts # 마에스트로 DB 쿼리
|
||||
│ ├── extension-requests.ts
|
||||
│ └── upgrade-requests.ts
|
||||
├── scripts/
|
||||
│ ├── deploy-stage.sh # 스테이지 배포 스크립트
|
||||
│ └── deploy-production.sh # 운영 배포 스크립트
|
||||
├── prisma/
|
||||
│ └── schema.prisma # DB introspection 결과
|
||||
├── docs/
|
||||
│ ├── business-rules.md # 기존 서비스 비즈니스 규칙 조사
|
||||
│ ├── deployment.md
|
||||
│ └── plan/project-plan.md # 전체 개발 계획
|
||||
├── auth.ts # NextAuth 설정
|
||||
├── proxy.ts # 미인증 접근 차단 미들웨어
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
## 문서
|
||||
|
||||
- [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획
|
||||
- [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙
|
||||
- [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ExtensionRequestsTable } from "@/components/data-table/extension-requests-table";
|
||||
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import {
|
||||
getExtensionRequests,
|
||||
type RawSearchParams,
|
||||
} from "@/lib/extension-requests";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ExtensionRequestsPageProps = {
|
||||
searchParams: Promise<RawSearchParams>;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
REQUEST_STATUSES.REQUESTED,
|
||||
REQUEST_STATUSES.CLOSED,
|
||||
REQUEST_STATUSES.APPLIED,
|
||||
] as const;
|
||||
|
||||
export default async function ExtensionRequestsPage({
|
||||
searchParams,
|
||||
}: ExtensionRequestsPageProps) {
|
||||
const params = await searchParams;
|
||||
const result = await getExtensionRequests(params);
|
||||
const paginationItems = buildPaginationItems(result.page, result.totalPages);
|
||||
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
|
||||
const hasFirstPage = paginationItems.includes(1);
|
||||
const hasLastPage = paginationItems.includes(result.totalPages);
|
||||
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||
const hasNextPageGroup = lastVisiblePage < result.totalPages;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">연장 신청</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {result.totalCount.toLocaleString()}건 중 {result.page} /{" "}
|
||||
{result.totalPages} 페이지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
|
||||
method="get"
|
||||
>
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
검색
|
||||
</span>
|
||||
<div className="relative min-w-0">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
defaultValue={result.q}
|
||||
name="q"
|
||||
placeholder="마에스트로명, ID, 신청ID"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
처리 상태
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="status"
|
||||
value={result.status ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{statusOptions.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{REQUEST_STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
페이지 크기
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="pageSize"
|
||||
value={result.pageSize}
|
||||
>
|
||||
{[10, 20, 50, 100].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
{pageSize}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
|
||||
<Button className="h-9" type="submit">
|
||||
적용
|
||||
</Button>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
className: "h-9",
|
||||
})}
|
||||
href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
|
||||
>
|
||||
초기화
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ExtensionRequestsTable data={result.items} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
페이지당 {result.pageSize}개 표시
|
||||
</p>
|
||||
<nav
|
||||
aria-label="연장 신청 목록 페이지"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
aria-label="처음 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, 1)}
|
||||
>
|
||||
<ChevronsLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{hasPreviousPageGroup ? (
|
||||
<Link
|
||||
aria-label="이전 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, Math.max(1, result.page - 5))}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{paginationItems.map((item) => (
|
||||
<Link
|
||||
aria-current={item === result.page ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: item === result.page ? "default" : "outline",
|
||||
size: "sm",
|
||||
}),
|
||||
"min-w-8 px-2"
|
||||
)}
|
||||
href={buildPageHref(params, item)}
|
||||
key={item}
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{hasNextPageGroup ? (
|
||||
<Link
|
||||
aria-label="다음 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(
|
||||
params,
|
||||
Math.min(result.totalPages, result.page + 5)
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{!hasLastPage ? (
|
||||
<Link
|
||||
aria-label="끝 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, result.totalPages)}
|
||||
>
|
||||
<ChevronsRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageHref(rawParams: RawSearchParams, page: number) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(rawParams).forEach(([key, value]) => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
if (firstValue && key !== "page") {
|
||||
params.set(key, firstValue);
|
||||
}
|
||||
});
|
||||
params.set("page", String(Math.max(1, page)));
|
||||
|
||||
return `/extension-requests?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, index) => startPage + index
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { logoutAction } from "./actions";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { navItems } from "./nav-config";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r bg-background md:block">
|
||||
<div className="flex h-16 items-center border-b px-5">
|
||||
<Link className="text-lg font-semibold" href="/">
|
||||
chocoadmin
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="space-y-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center gap-3 border-b bg-background px-5">
|
||||
<MobileNav />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
관리자 ID {session.user.adminID}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutAction}>
|
||||
<Button size="sm" type="submit" variant="outline">
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
로그아웃
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main className="p-5">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
||||
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
|
||||
import type { MaestroDetail } from "@/lib/maestros";
|
||||
|
||||
type ExtensionRequestsSectionProps = {
|
||||
maestroID: number;
|
||||
accountType: number;
|
||||
totalCount: number;
|
||||
extensionRequests: MaestroDetail["extensionRequests"];
|
||||
};
|
||||
|
||||
export function ExtensionRequestsSection({
|
||||
maestroID,
|
||||
accountType,
|
||||
totalCount,
|
||||
extensionRequests,
|
||||
}: ExtensionRequestsSectionProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isPaidAccount = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
|
||||
const isDisabled = isCreating || isPending;
|
||||
|
||||
async function handleCreate() {
|
||||
if (!window.confirm("연장 신청을 등록하시겠습니까?")) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/maestros/${maestroID}/extension-requests`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "연장 신청 등록에 실패했습니다.");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "연장 신청 등록에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">연장 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button
|
||||
disabled={isDisabled || !isPaidAccount}
|
||||
onClick={() => void handleCreate()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
연장
|
||||
</Button>
|
||||
{errorMessage ? (
|
||||
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
{["신청ID", "계정 유형", "신청일시", "상태"].map((header) => (
|
||||
<th
|
||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
||||
key={header}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{extensionRequests.length > 0 ? (
|
||||
extensionRequests.map((request) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={request.maestroExtensionID}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{request.maestroExtensionID}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{getAccountTypeLabel(request.accountType)}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{formatDateTime(request.requestedDateTime)}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{getRequestStatusLabel(request.status)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={4}
|
||||
>
|
||||
연장 신청 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { MaestroLogsResult } from "@/lib/maestros";
|
||||
|
||||
type LogsSectionProps = {
|
||||
maestroID: number;
|
||||
};
|
||||
|
||||
export function LogsSection({ maestroID }: LogsSectionProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [data, setData] = useState<MaestroLogsResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`/api/maestros/${maestroID}/logs?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("처리 로그를 불러오지 못했습니다.");
|
||||
}
|
||||
|
||||
const result = (await response.json()) as MaestroLogsResult;
|
||||
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "처리 로그를 불러오지 못했습니다."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [maestroID, page, pageSize]);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">처리 로그</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data ? `전체 ${data.totalCount.toLocaleString()}건` : "로딩 중..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
<LogTable isLoading={isLoading} result={data} />
|
||||
{data ? (
|
||||
<LogPagination
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
page={data.page}
|
||||
pageSize={pageSize}
|
||||
totalPages={data.totalPages}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LogTable({
|
||||
result,
|
||||
isLoading,
|
||||
}: {
|
||||
result: MaestroLogsResult | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading || !result) {
|
||||
return (
|
||||
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
LogID
|
||||
</th>
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
유형
|
||||
</th>
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
처리일시
|
||||
</th>
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
내용
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.length > 0 ? (
|
||||
result.items.map((log) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={log.maestroLogID}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{log.maestroLogID}
|
||||
</span>
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">{log.type}</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{formatDateTime(new Date(log.logDateTime))}
|
||||
</td>
|
||||
<td className="h-11 max-w-[360px] break-words px-3 align-middle">
|
||||
{log.remark}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={4}
|
||||
>
|
||||
처리 로그가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogPagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) {
|
||||
const showNav = totalPages > 1;
|
||||
const paginationItems = buildPaginationItems(page, totalPages);
|
||||
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||
const lastVisiblePage = paginationItems.at(-1) ?? totalPages;
|
||||
const hasFirstPage = paginationItems.includes(1);
|
||||
const hasLastPage = paginationItems.includes(totalPages);
|
||||
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||
const hasNextPageGroup = lastVisiblePage < totalPages;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||
<select
|
||||
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
value={pageSize}
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||
</label>
|
||||
{showNav ? (
|
||||
<nav
|
||||
aria-label="처리 로그 페이지"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Button
|
||||
aria-label="처음 페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronsLeft aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasPreviousPageGroup ? (
|
||||
<Button
|
||||
aria-label="이전 5페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(Math.max(1, page - 5))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{paginationItems.map((item) => (
|
||||
<Button
|
||||
aria-current={item === page ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: item === page ? "default" : "outline",
|
||||
size: "sm",
|
||||
}),
|
||||
"min-w-8 px-2"
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => onPageChange(item)}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{hasNextPageGroup ? (
|
||||
<Button
|
||||
aria-label="다음 5페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!hasLastPage ? (
|
||||
<Button
|
||||
aria-label="끝 페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronsRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, index) => startPage + index
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_TYPES,
|
||||
ACTIVATE_STATUS_LABELS,
|
||||
ACTIVATE_STATUSES,
|
||||
} from "@/lib/constants";
|
||||
import { formatDate, formatDateTime, toDatetimeLocal } from "@/lib/utils";
|
||||
import type { MaestroDetail } from "@/lib/maestros";
|
||||
|
||||
type MaestroEditFormProps = {
|
||||
maestro: MaestroDetail["maestro"];
|
||||
playerCount: number;
|
||||
};
|
||||
|
||||
const paidAccountTypeOptions = [
|
||||
ACCOUNT_TYPES.BASIC_20,
|
||||
ACCOUNT_TYPES.STANDARD_50,
|
||||
ACCOUNT_TYPES.PRO_100,
|
||||
ACCOUNT_TYPES.SCHOOL_500,
|
||||
ACCOUNT_TYPES.SCHOOL_1000,
|
||||
] as const;
|
||||
|
||||
const activateStatusOptions = [
|
||||
ACTIVATE_STATUSES.PENDING,
|
||||
ACTIVATE_STATUSES.TRIAL,
|
||||
ACTIVATE_STATUSES.ACTIVE,
|
||||
ACTIVATE_STATUSES.CANCELED,
|
||||
] as const;
|
||||
|
||||
const inputClass =
|
||||
"h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30 disabled:opacity-50";
|
||||
|
||||
export function MaestroEditForm({ maestro, playerCount }: MaestroEditFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
const [name, setName] = useState(maestro.name);
|
||||
const [email, setEmail] = useState(maestro.email);
|
||||
const [activateStatus, setActivateStatus] = useState(maestro.activateStatus);
|
||||
const [accountType, setAccountType] = useState(maestro.accountType);
|
||||
const [availableActivateDateTime, setAvailableActivateDateTime] = useState(
|
||||
toDatetimeLocal(maestro.availableActivateDateTime)
|
||||
);
|
||||
const [allowEditEnterCode, setAllowEditEnterCode] = useState(
|
||||
maestro.allowEditEnterCode
|
||||
);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
|
||||
const isDisabled = isSaving || isPending || isResettingPassword;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!window.confirm("마에스트로 정보를 저장하시겠습니까?")) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
setSuccessMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/maestros/${maestro.maestroID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
activateStatus,
|
||||
accountType,
|
||||
availableActivateDateTime,
|
||||
allowEditEnterCode,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
changed?: boolean;
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "저장에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (body?.changed) {
|
||||
setSuccessMessage("저장 완료");
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} else {
|
||||
setSuccessMessage("변경 사항이 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "저장에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (
|
||||
!window.confirm(
|
||||
`${maestro.name} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setIsResettingPassword(true);
|
||||
setErrorMessage("");
|
||||
setSuccessMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/maestros/${maestro.maestroID}/reset-password`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "암호 초기화에 실패했습니다.");
|
||||
}
|
||||
|
||||
setSuccessMessage("암호 초기화 완료");
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "암호 초기화에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsResettingPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="mt-4 space-y-5">
|
||||
<div className="grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||
<FormField label="이름">
|
||||
<input
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
maxLength={50}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="이메일">
|
||||
<input
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
maxLength={50}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="상태">
|
||||
<select
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => setActivateStatus(Number(e.target.value))}
|
||||
value={activateStatus}
|
||||
>
|
||||
{activateStatusOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{ACTIVATE_STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="계정 유형">
|
||||
<select
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => setAccountType(Number(e.target.value))}
|
||||
value={accountType}
|
||||
>
|
||||
{paidAccountTypeOptions.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{ACCOUNT_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="사용 가능일">
|
||||
<input
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => setAvailableActivateDateTime(e.target.value)}
|
||||
required
|
||||
type="datetime-local"
|
||||
value={availableActivateDateTime}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="입장코드 수정">
|
||||
<select
|
||||
className={inputClass}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => setAllowEditEnterCode(Number(e.target.value))}
|
||||
value={allowEditEnterCode}
|
||||
>
|
||||
<option value={1}>허용</option>
|
||||
<option value={0}>비허용</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-x-8 gap-y-3 border-t pt-4 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||
<ReadOnlyItem
|
||||
label="약관 동의일"
|
||||
value={formatDateTime(maestro.acceptClausesDateTime)}
|
||||
/>
|
||||
<ReadOnlyItem
|
||||
label="DB 학생 수"
|
||||
value={playerCount.toLocaleString()}
|
||||
/>
|
||||
<ReadOnlyItem
|
||||
label="PlayerCount"
|
||||
value={maestro.playerCount.toLocaleString()}
|
||||
/>
|
||||
<ReadOnlyItem
|
||||
label="체험 MaestroID"
|
||||
value={maestro.maestroTestID?.toString() ?? "-"}
|
||||
/>
|
||||
<ReadOnlyItem
|
||||
label="사용 가능일 (현재)"
|
||||
value={formatDate(maestro.availableActivateDateTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button disabled={isDisabled} size="sm" type="submit">
|
||||
<Save aria-hidden="true" className="size-4" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
onClick={() => void handleResetPassword()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<KeyRound aria-hidden="true" className="size-4" />
|
||||
암호 초기화
|
||||
</Button>
|
||||
{successMessage ? (
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{successMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MaestroStudentsResult } from "@/lib/maestros";
|
||||
|
||||
type StudentsSectionProps = {
|
||||
maestroID: number;
|
||||
};
|
||||
|
||||
export function StudentsSection({ maestroID }: StudentsSectionProps) {
|
||||
const [q, setQ] = useState("");
|
||||
const [submittedQ, setSubmittedQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [data, setData] = useState<MaestroStudentsResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
q: submittedQ,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`/api/maestros/${maestroID}/students?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("학생 목록을 불러오지 못했습니다.");
|
||||
}
|
||||
|
||||
const result = (await response.json()) as MaestroStudentsResult;
|
||||
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "학생 목록을 불러오지 못했습니다."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [maestroID, submittedQ, page, pageSize]);
|
||||
|
||||
function handleSearch(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
setSubmittedQ(q);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">학생 목록</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data
|
||||
? `전체 ${data.totalCount.toLocaleString()}명${submittedQ ? ` — "${submittedQ}" 검색 결과` : ""}`
|
||||
: "로딩 중..."}
|
||||
</p>
|
||||
</div>
|
||||
<form className="flex gap-2" onSubmit={handleSearch}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-44 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="이름 검색"
|
||||
type="search"
|
||||
value={q}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" type="submit">
|
||||
검색
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
<StudentTable isLoading={isLoading} result={data} />
|
||||
{data ? (
|
||||
<StudentPagination
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
page={data.page}
|
||||
pageSize={pageSize}
|
||||
totalPages={data.totalPages}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentTable({
|
||||
result,
|
||||
isLoading,
|
||||
}: {
|
||||
result: MaestroStudentsResult | null;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading || !result) {
|
||||
return (
|
||||
<div className="mt-4 flex h-24 items-center justify-center rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[360px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
PlayerID
|
||||
</th>
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
이름
|
||||
</th>
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
입장코드
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.length > 0 ? (
|
||||
result.items.map((player) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={player.playerID}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{player.playerID}
|
||||
</span>
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">{player.name}</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<span className="font-mono text-xs">{player.enterCode}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-24 text-center text-sm text-muted-foreground"
|
||||
colSpan={3}
|
||||
>
|
||||
{result.q
|
||||
? "검색 결과가 없습니다."
|
||||
: "등록된 학생이 없습니다."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudentPagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) {
|
||||
const showNav = totalPages > 1;
|
||||
const paginationItems = buildPaginationItems(page, totalPages);
|
||||
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||
const lastVisiblePage = paginationItems.at(-1) ?? totalPages;
|
||||
const hasFirstPage = paginationItems.includes(1);
|
||||
const hasLastPage = paginationItems.includes(totalPages);
|
||||
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||
const hasNextPageGroup = lastVisiblePage < totalPages;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||
<select
|
||||
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
value={pageSize}
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||
</label>
|
||||
{showNav ? <nav
|
||||
aria-label="학생 목록 페이지"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Button
|
||||
aria-label="처음 페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(1)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronsLeft aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{hasPreviousPageGroup ? (
|
||||
<Button
|
||||
aria-label="이전 5페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(Math.max(1, page - 5))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{paginationItems.map((item) => (
|
||||
<Button
|
||||
aria-current={item === page ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: item === page ? "default" : "outline",
|
||||
size: "sm",
|
||||
}),
|
||||
"min-w-8 px-2"
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => onPageChange(item)}
|
||||
type="button"
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{hasNextPageGroup ? (
|
||||
<Button
|
||||
aria-label="다음 5페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 5))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!hasLastPage ? (
|
||||
<Button
|
||||
aria-label="끝 페이지"
|
||||
className={buttonVariants({ variant: "outline", size: "icon-sm" })}
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronsRight aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</nav> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, index) => startPage + index
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ACCOUNT_TYPES, ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
|
||||
import type { MaestroDetail } from "@/lib/maestros";
|
||||
|
||||
type UpgradeRequestsSectionProps = {
|
||||
maestroID: number;
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
totalCount: number;
|
||||
upgradeRequests: MaestroDetail["upgradeRequests"];
|
||||
};
|
||||
|
||||
const upgradeOptions = [
|
||||
{ value: ACCOUNT_TYPES.BASIC_20, label: "1만원 (20명)" },
|
||||
{ value: ACCOUNT_TYPES.STANDARD_50, label: "2만원 (50명)" },
|
||||
{ value: ACCOUNT_TYPES.PRO_100, label: "3만원 (100명)" },
|
||||
{ value: ACCOUNT_TYPES.SCHOOL_500, label: "4만원 (500명)" },
|
||||
{ value: ACCOUNT_TYPES.SCHOOL_1000, label: "5만원 (1,000명)" },
|
||||
] as const;
|
||||
|
||||
const selectClass =
|
||||
"h-9 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30 disabled:opacity-50";
|
||||
|
||||
export function UpgradeRequestsSection({
|
||||
maestroID,
|
||||
accountType,
|
||||
activateStatus,
|
||||
totalCount,
|
||||
upgradeRequests,
|
||||
}: UpgradeRequestsSectionProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedAccountType, setSelectedAccountType] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isTrial = activateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||
const isUpgradeEnabled =
|
||||
selectedAccountType !== null &&
|
||||
(isTrial ? selectedAccountType >= accountType : selectedAccountType > accountType);
|
||||
const isDisabled = isCreating || isPending;
|
||||
|
||||
async function handleUpgrade() {
|
||||
if (selectedAccountType === null) return;
|
||||
if (!window.confirm("업그레이드 신청을 등록하시겠습니까?")) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/maestros/${maestroID}/upgrade-requests`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requestedAccountType: selectedAccountType }),
|
||||
}
|
||||
);
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "업그레이드 신청 등록에 실패했습니다.");
|
||||
}
|
||||
|
||||
setSelectedAccountType(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "업그레이드 신청 등록에 실패했습니다."
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-lg border bg-background p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">업그레이드 신청 이력</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {totalCount.toLocaleString()}건 중 최근 20건
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className={selectClass}
|
||||
disabled={isDisabled}
|
||||
onChange={(e) =>
|
||||
setSelectedAccountType(
|
||||
e.target.value === "" ? null : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
value={selectedAccountType ?? ""}
|
||||
>
|
||||
<option value="">요금제 선택</option>
|
||||
{upgradeOptions.map((opt) => (
|
||||
<option
|
||||
disabled={isTrial ? opt.value < accountType : opt.value <= accountType}
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
disabled={isDisabled || !isUpgradeEnabled}
|
||||
onClick={() => void handleUpgrade()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp aria-hidden="true" className="size-4" />
|
||||
업그레이드
|
||||
</Button>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p className="text-xs text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-lg border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
{["신청ID", "현재", "요청", "신청일시", "상태"].map(
|
||||
(header) => (
|
||||
<th
|
||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
||||
key={header}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upgradeRequests.length > 0 ? (
|
||||
upgradeRequests.map((request) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={request.maestroUpgradeID}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{request.maestroUpgradeID}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{getAccountTypeLabel(request.registeredAccountType)}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{getAccountTypeLabel(request.requestedAccountType)}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{formatDateTime(request.requestedDateTime)}
|
||||
</td>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
{getRequestStatusLabel(request.status)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-24 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={5}
|
||||
>
|
||||
업그레이드 신청 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { getMaestroDetail } from "@/lib/maestros";
|
||||
import {
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
} from "@/lib/utils";
|
||||
import { MaestroEditForm } from "./MaestroEditForm";
|
||||
import { StudentsSection } from "./StudentsSection";
|
||||
import { ExtensionRequestsSection } from "./ExtensionRequestsSection";
|
||||
import { UpgradeRequestsSection } from "./UpgradeRequestsSection";
|
||||
import { LogsSection } from "./LogsSection";
|
||||
|
||||
type MaestroDetailPageProps = {
|
||||
params: Promise<{
|
||||
maestroId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function MaestroDetailPage({
|
||||
params,
|
||||
}: MaestroDetailPageProps) {
|
||||
const { maestroId } = await params;
|
||||
const maestroID = Number(maestroId);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const detail = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!detail) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { maestro } = detail;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
className: "mb-2 -ml-2",
|
||||
})}
|
||||
href="/maestros"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
목록
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
{maestro.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
MaestroID {maestro.maestroID} · {maestro.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
<SummaryCard
|
||||
label="상태"
|
||||
value={getActivateStatusLabel(maestro.activateStatus)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="계정 유형"
|
||||
value={getAccountTypeLabel(maestro.accountType)}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="학생 수"
|
||||
value={detail.counts.players.toLocaleString()}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="사용 가능일"
|
||||
value={formatDate(maestro.availableActivateDateTime)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-background p-5">
|
||||
<h2 className="text-base font-semibold">기본 정보</h2>
|
||||
<MaestroEditForm
|
||||
maestro={detail.maestro}
|
||||
playerCount={detail.counts.players}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<StudentsSection maestroID={maestroID} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<ExtensionRequestsSection
|
||||
accountType={maestro.accountType}
|
||||
extensionRequests={detail.extensionRequests}
|
||||
maestroID={maestroID}
|
||||
totalCount={detail.counts.extensionRequests}
|
||||
/>
|
||||
|
||||
<UpgradeRequestsSection
|
||||
accountType={maestro.accountType}
|
||||
activateStatus={maestro.activateStatus}
|
||||
maestroID={maestroID}
|
||||
totalCount={detail.counts.upgradeRequests}
|
||||
upgradeRequests={detail.upgradeRequests}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogsSection maestroID={maestroID} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<article className="rounded-lg border bg-background p-4">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-2 text-lg font-semibold">{value}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { MaestrosTable } from "@/components/data-table/maestros-table";
|
||||
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
|
||||
import {
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_TYPES,
|
||||
ACTIVATE_STATUS_LABELS,
|
||||
ACTIVATE_STATUSES,
|
||||
} from "@/lib/constants";
|
||||
import { getMaestros, type RawSearchParams } from "@/lib/maestros";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MaestrosPageProps = {
|
||||
searchParams: Promise<RawSearchParams>;
|
||||
};
|
||||
|
||||
const activateStatusOptions = [
|
||||
ACTIVATE_STATUSES.PENDING,
|
||||
ACTIVATE_STATUSES.TRIAL,
|
||||
ACTIVATE_STATUSES.ACTIVE,
|
||||
ACTIVATE_STATUSES.CANCELED,
|
||||
] as const;
|
||||
|
||||
const accountTypeOptions = [
|
||||
ACCOUNT_TYPES.BASIC_20,
|
||||
ACCOUNT_TYPES.STANDARD_50,
|
||||
ACCOUNT_TYPES.PRO_100,
|
||||
ACCOUNT_TYPES.SCHOOL_500,
|
||||
ACCOUNT_TYPES.SCHOOL_1000,
|
||||
] as const;
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "id-desc", label: "ID 최신순" },
|
||||
{ value: "id-asc", label: "ID 오래된순" },
|
||||
{ value: "joinedAt-desc", label: "가입일 최신순" },
|
||||
{ value: "joinedAt-asc", label: "가입일 오래된순" },
|
||||
{ value: "availableAt-desc", label: "만료일 최신순" },
|
||||
{ value: "availableAt-asc", label: "만료일 오래된순" },
|
||||
] as const;
|
||||
|
||||
export default async function MaestrosPage({
|
||||
searchParams,
|
||||
}: MaestrosPageProps) {
|
||||
const params = await searchParams;
|
||||
const result = await getMaestros(params);
|
||||
const paginationItems = buildPaginationItems(result.page, result.totalPages);
|
||||
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
|
||||
const hasFirstPage = paginationItems.includes(1);
|
||||
const hasLastPage = paginationItems.includes(result.totalPages);
|
||||
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||
const hasNextPageGroup = lastVisiblePage < result.totalPages;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">마에스트로</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {result.totalCount.toLocaleString()}명 중 {result.page} /{" "}
|
||||
{result.totalPages} 페이지
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1fr)_160px_180px_190px_auto]"
|
||||
method="get"
|
||||
>
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
검색
|
||||
</span>
|
||||
<div className="relative min-w-0">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
defaultValue={result.q}
|
||||
name="q"
|
||||
placeholder="이름, 이메일, ID"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
상태
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="activateStatus"
|
||||
value={result.activateStatus ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{activateStatusOptions.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{ACTIVATE_STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
계정 유형
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="accountType"
|
||||
value={result.accountType ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{accountTypeOptions.map((accountType) => (
|
||||
<option key={accountType} value={accountType}>
|
||||
{ACCOUNT_TYPE_LABELS[accountType]}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
정렬
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="sort"
|
||||
value={result.sort}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
|
||||
<Button className="h-9" type="submit">
|
||||
적용
|
||||
</Button>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
className: "h-9",
|
||||
})}
|
||||
href="/maestros"
|
||||
>
|
||||
초기화
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<MaestrosTable data={result.items} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<form className="flex items-center gap-2" method="get">
|
||||
{Object.entries(params).flatMap(([key, value]) => {
|
||||
if (key === "page" || key === "pageSize") return [];
|
||||
const v = Array.isArray(value) ? value[0] : value;
|
||||
if (!v) return [];
|
||||
return [<input key={key} name={key} type="hidden" value={v} />];
|
||||
})}
|
||||
<span className="text-sm text-muted-foreground">페이지당</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-7 rounded-md border bg-background px-2 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="pageSize"
|
||||
value={result.pageSize}
|
||||
>
|
||||
{[10, 20, 50, 100].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
<span className="text-sm text-muted-foreground">개 표시</span>
|
||||
</form>
|
||||
<nav
|
||||
aria-label="마에스트로 목록 페이지"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
aria-label="처음 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, 1)}
|
||||
>
|
||||
<ChevronsLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{hasPreviousPageGroup ? (
|
||||
<Link
|
||||
aria-label="이전 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, Math.max(1, result.page - 5))}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{paginationItems.map((item) => (
|
||||
<Link
|
||||
aria-current={item === result.page ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: item === result.page ? "default" : "outline",
|
||||
size: "sm",
|
||||
}),
|
||||
"min-w-8 px-2"
|
||||
)}
|
||||
href={buildPageHref(params, item)}
|
||||
key={item}
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{hasNextPageGroup ? (
|
||||
<Link
|
||||
aria-label="다음 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(
|
||||
params,
|
||||
Math.min(result.totalPages, result.page + 5)
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{!hasLastPage ? (
|
||||
<Link
|
||||
aria-label="끝 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, result.totalPages)}
|
||||
>
|
||||
<ChevronsRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageHref(rawParams: RawSearchParams, page: number) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(rawParams).forEach(([key, value]) => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
if (firstValue && key !== "page") {
|
||||
params.set(key, firstValue);
|
||||
}
|
||||
});
|
||||
params.set("page", String(Math.max(1, page)));
|
||||
|
||||
return `/maestros?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, index) => startPage + index
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Database, ShieldCheck, Table2 } from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const checks = [
|
||||
{
|
||||
label: "Next.js 16",
|
||||
value: "App Router",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: "Prisma",
|
||||
value: "MariaDB introspected",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "TanStack Table",
|
||||
value: "Installed",
|
||||
icon: Table2,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">대시보드</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{session?.user.name} 관리자 계정으로 로그인했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{checks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-card p-5 text-card-foreground"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium">{item.label}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.value}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { UpgradeRequestsTable } from "@/components/data-table/upgrade-requests-table";
|
||||
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import {
|
||||
getUpgradeRequests,
|
||||
type RawSearchParams,
|
||||
} from "@/lib/upgrade-requests";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type UpgradeRequestsPageProps = {
|
||||
searchParams: Promise<RawSearchParams>;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
REQUEST_STATUSES.REQUESTED,
|
||||
REQUEST_STATUSES.CLOSED,
|
||||
REQUEST_STATUSES.APPLIED,
|
||||
] as const;
|
||||
|
||||
export default async function UpgradeRequestsPage({
|
||||
searchParams,
|
||||
}: UpgradeRequestsPageProps) {
|
||||
const params = await searchParams;
|
||||
const result = await getUpgradeRequests(params);
|
||||
const paginationItems = buildPaginationItems(result.page, result.totalPages);
|
||||
const firstVisiblePage = paginationItems[0] ?? 1;
|
||||
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
|
||||
const hasFirstPage = paginationItems.includes(1);
|
||||
const hasLastPage = paginationItems.includes(result.totalPages);
|
||||
const hasPreviousPageGroup = firstVisiblePage > 1;
|
||||
const hasNextPageGroup = lastVisiblePage < result.totalPages;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
업그레이드 신청
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
전체 {result.totalCount.toLocaleString()}건 중 {result.page} /{" "}
|
||||
{result.totalPages} 페이지
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
|
||||
method="get"
|
||||
>
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
검색
|
||||
</span>
|
||||
<div className="relative min-w-0">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
defaultValue={result.q}
|
||||
name="q"
|
||||
placeholder="마에스트로명, ID, 신청ID"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
처리 상태
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="status"
|
||||
value={result.status ?? "all"}
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{statusOptions.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{REQUEST_STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<label className="min-w-0 space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
페이지 크기
|
||||
</span>
|
||||
<AutoSubmitSelect
|
||||
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
name="pageSize"
|
||||
value={result.pageSize}
|
||||
>
|
||||
{[10, 20, 50, 100].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
{pageSize}
|
||||
</option>
|
||||
))}
|
||||
</AutoSubmitSelect>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
|
||||
<Button className="h-9" type="submit">
|
||||
적용
|
||||
</Button>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
className: "h-9",
|
||||
})}
|
||||
href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
|
||||
>
|
||||
초기화
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<UpgradeRequestsTable data={result.items} />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
페이지당 {result.pageSize}개 표시
|
||||
</p>
|
||||
<nav
|
||||
aria-label="업그레이드 신청 목록 페이지"
|
||||
className="self-end flex flex-wrap items-center gap-1.5 sm:self-auto"
|
||||
>
|
||||
{!hasFirstPage ? (
|
||||
<Link
|
||||
aria-label="처음 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, 1)}
|
||||
>
|
||||
<ChevronsLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{hasPreviousPageGroup ? (
|
||||
<Link
|
||||
aria-label="이전 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, Math.max(1, result.page - 5))}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{paginationItems.map((item) => (
|
||||
<Link
|
||||
aria-current={item === result.page ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: item === result.page ? "default" : "outline",
|
||||
size: "sm",
|
||||
}),
|
||||
"min-w-8 px-2"
|
||||
)}
|
||||
href={buildPageHref(params, item)}
|
||||
key={item}
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{hasNextPageGroup ? (
|
||||
<Link
|
||||
aria-label="다음 5페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(
|
||||
params,
|
||||
Math.min(result.totalPages, result.page + 5)
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{!hasLastPage ? (
|
||||
<Link
|
||||
aria-label="끝 페이지"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
})}
|
||||
href={buildPageHref(params, result.totalPages)}
|
||||
>
|
||||
<ChevronsRight className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageHref(rawParams: RawSearchParams, page: number) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(rawParams).forEach(([key, value]) => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
if (firstValue && key !== "page") {
|
||||
params.set(key, firstValue);
|
||||
}
|
||||
});
|
||||
params.set("page", String(Math.max(1, page)));
|
||||
|
||||
return `/upgrade-requests?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildPaginationItems(currentPage: number, totalPages: number) {
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
return Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, index) => startPage + index
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
function sanitizeCallbackUrl(raw: string): string {
|
||||
try {
|
||||
const parsed = new URL(raw, "http://x");
|
||||
if (parsed.origin !== "http://x") return "/";
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
return raw.startsWith("/") ? raw : "/";
|
||||
}
|
||||
|
||||
export async function loginAction(formData: FormData) {
|
||||
const callbackUrl = sanitizeCallbackUrl(
|
||||
String(formData.get("callbackUrl") ?? "/")
|
||||
);
|
||||
|
||||
try {
|
||||
const nextAuthRedirectUrl = await signIn("credentials", {
|
||||
adminName: formData.get("adminName"),
|
||||
password: formData.get("password"),
|
||||
redirect: false,
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
|
||||
logger.info("auth", "login redirect", {
|
||||
nextAuthRedirectUrl: String(nextAuthRedirectUrl),
|
||||
callbackUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
redirect(
|
||||
`/login?error=CredentialsSignin&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
redirect(callbackUrl);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { loginAction } from "./actions";
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
callbackUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const callbackUrl = params.callbackUrl ?? "/";
|
||||
const hasError = params.error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-10">
|
||||
<section className="w-full max-w-md rounded-lg border bg-background p-8 shadow-sm">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">chocoadmin</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">관리자 로그인</p>
|
||||
</div>
|
||||
|
||||
{hasError ? (
|
||||
<div className="mb-5 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
관리자 아이디 또는 암호가 올바르지 않습니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={loginAction} className="space-y-5">
|
||||
<input name="callbackUrl" type="hidden" value={callbackUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="adminName">
|
||||
관리자 아이디
|
||||
</label>
|
||||
<input
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="adminName"
|
||||
name="adminName"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
암호
|
||||
</label>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" type="submit">
|
||||
로그인
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/auth";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
approveExtensionRequest,
|
||||
cancelExtensionRequest,
|
||||
} from "@/lib/extension-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { sendExtensionDoneEmail } from "@/lib/mail";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const CTX = "extension-requests/[id]";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
action: z.enum(["approve", "cancel"]),
|
||||
});
|
||||
|
||||
export const PATCH = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroExtensionID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
|
||||
throw new ApiError("Invalid extension request id", 400);
|
||||
}
|
||||
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid action", 400);
|
||||
}
|
||||
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "approved", {
|
||||
id: maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
const emailSent = await sendExtensionDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
formatDate(result.availableActivateDateTime)
|
||||
);
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
|
||||
}
|
||||
|
||||
await cancelExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "cancelled", {
|
||||
id: maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getExtensionRequests } from "@/lib/extension-requests";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export const GET = withApiHandler("extension-requests", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getExtensionRequests(url.searchParams);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createExtensionRequest } from "@/lib/extension-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const CTX = "maestros/[id]/extension-requests";
|
||||
|
||||
export const POST = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (_request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
const result = await createExtensionRequest(maestroID);
|
||||
|
||||
logger.info(CTX, "created", {
|
||||
maestroID,
|
||||
maestroExtensionID: result.maestroExtensionID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
);
|
||||
@@ -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,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getMaestroDetail, updateMaestro, updateMaestroSchema } from "@/lib/maestros";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const CTX = "maestros/[id]";
|
||||
|
||||
export const GET = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (_request, { params }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
const result = await getMaestroDetail(maestroID);
|
||||
|
||||
if (!result) {
|
||||
throw new ApiError("Not found", 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
export const PATCH = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
const body = updateMaestroSchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid request body", 400);
|
||||
}
|
||||
|
||||
const result = await updateMaestro(maestroID, body.data);
|
||||
|
||||
if (!result) {
|
||||
throw new ApiError("Not found", 404);
|
||||
}
|
||||
|
||||
logger.info(CTX, result.changed ? "updated" : "no change", {
|
||||
id: maestroID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getMaestroStudents } from "@/lib/maestros";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
const CTX = "maestros/[id]/students";
|
||||
|
||||
export const GET = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const result = await getMaestroStudents(maestroID, url.searchParams);
|
||||
|
||||
if (!result) {
|
||||
throw new ApiError("Not found", 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createUpgradeRequest } from "@/lib/upgrade-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
||||
|
||||
const CTX = "maestros/[id]/upgrade-requests";
|
||||
|
||||
const postBodySchema = z.object({
|
||||
requestedAccountType: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v)),
|
||||
});
|
||||
|
||||
export const POST = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroID) || maestroID <= 0) {
|
||||
throw new ApiError("Invalid maestro id", 400);
|
||||
}
|
||||
|
||||
const body = postBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid request body", 400);
|
||||
}
|
||||
|
||||
const result = await createUpgradeRequest(
|
||||
maestroID,
|
||||
body.data.requestedAccountType
|
||||
);
|
||||
|
||||
logger.info(CTX, "created", {
|
||||
maestroID,
|
||||
maestroUpgradeID: result.maestroUpgradeID,
|
||||
requestedAccountType: body.data.requestedAccountType,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getMaestros } from "@/lib/maestros";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export const GET = withApiHandler("maestros", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getMaestros(url.searchParams);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
approveUpgradeRequest,
|
||||
rejectUpgradeRequest,
|
||||
} from "@/lib/upgrade-requests";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { sendUpgradeDoneEmail } from "@/lib/mail";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const CTX = "upgrade-requests/[id]";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
action: z.enum(["approve", "reject"]),
|
||||
});
|
||||
|
||||
export const PATCH = withApiHandler<{ id: string }>(
|
||||
CTX,
|
||||
async (request, { params, t0 }) => {
|
||||
const { id } = await params;
|
||||
const maestroUpgradeID = Number(id);
|
||||
|
||||
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
|
||||
throw new ApiError("Invalid upgrade request id", 400);
|
||||
}
|
||||
|
||||
const body = patchBodySchema.safeParse(
|
||||
await request.json().catch(() => ({}))
|
||||
);
|
||||
if (!body.success) {
|
||||
throw new ApiError("Invalid action", 400);
|
||||
}
|
||||
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "approved", {
|
||||
id: maestroUpgradeID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
const emailSent = await sendUpgradeDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
result.registeredActivateStatus,
|
||||
result.registeredAccountType,
|
||||
result.requestedAccountType,
|
||||
formatDate(result.availableActivateDateTime)
|
||||
);
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime, emailSent });
|
||||
}
|
||||
|
||||
await rejectUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "rejected", {
|
||||
id: maestroUpgradeID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getUpgradeRequests } from "@/lib/upgrade-requests";
|
||||
import { withApiHandler } from "@/lib/api-handler";
|
||||
|
||||
export const GET = withApiHandler("upgrade-requests", async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const result = await getUpgradeRequests(url.searchParams);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
+130
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: Arial, Helvetica, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "chocoadmin",
|
||||
description: "Chocomae admin service",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const AUTH_CTX = "auth";
|
||||
|
||||
type AttemptRecord = { count: number; until: number };
|
||||
const loginAttempts = new Map<string, AttemptRecord>();
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
function isLockedOut(adminName: string): boolean {
|
||||
const record = loginAttempts.get(adminName);
|
||||
if (!record || record.count < MAX_ATTEMPTS) return false;
|
||||
if (record.until > Date.now()) return true;
|
||||
loginAttempts.delete(adminName);
|
||||
return false;
|
||||
}
|
||||
|
||||
function recordFailure(adminName: string): void {
|
||||
const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 };
|
||||
record.count += 1;
|
||||
if (record.count >= MAX_ATTEMPTS) {
|
||||
record.until = Date.now() + LOCKOUT_MS;
|
||||
}
|
||||
loginAttempts.set(adminName, record);
|
||||
}
|
||||
|
||||
function clearAttempts(adminName: string): void {
|
||||
loginAttempts.delete(adminName);
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
adminName: z.string().trim().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
type AdminAuthRow = {
|
||||
AdminID: bigint | number;
|
||||
Name: string;
|
||||
Email: string;
|
||||
AccountType: bigint | number;
|
||||
ActivateStatus: bigint | number;
|
||||
};
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
adminID: number;
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
accountType?: number;
|
||||
activateStatus?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const appEnv = process.env.APP_ENV ?? "local";
|
||||
const isSecure = process.env.NODE_ENV === "production";
|
||||
const sessionCookieName = `chocoadmin-${appEnv}.session-token`;
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth({
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 8 * 60 * 60,
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: sessionCookieName,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: isSecure,
|
||||
},
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
adminName: { label: "관리자 아이디", type: "text" },
|
||||
password: { label: "암호", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const parsedCredentials = credentialsSchema.safeParse(credentials);
|
||||
|
||||
if (!parsedCredentials.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { adminName, password } = parsedCredentials.data;
|
||||
|
||||
if (isLockedOut(adminName)) {
|
||||
logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { db } = await import("@/lib/db");
|
||||
const admins = await db.$queryRaw<AdminAuthRow[]>`
|
||||
SELECT AdminID, Name, Email, AccountType, ActivateStatus
|
||||
FROM admin
|
||||
WHERE Name = ${adminName} AND Password = PASSWORD(${password})
|
||||
LIMIT 1
|
||||
`;
|
||||
const admin = admins[0];
|
||||
const adminID = Number(admin?.AdminID);
|
||||
const accountType = Number(admin?.AccountType);
|
||||
const activateStatus = Number(admin?.ActivateStatus);
|
||||
|
||||
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
|
||||
recordFailure(adminName);
|
||||
logger.warn(AUTH_CTX, "login failed", { adminName });
|
||||
return null;
|
||||
}
|
||||
|
||||
clearAttempts(adminName);
|
||||
logger.info(AUTH_CTX, "login success", { adminName, adminID });
|
||||
return {
|
||||
id: String(adminID),
|
||||
name: admin.Name,
|
||||
email: admin.Email,
|
||||
accountType,
|
||||
activateStatus,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.accountType = user.accountType;
|
||||
token.activateStatus = user.activateStatus;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
adminID: Number(token.sub),
|
||||
accountType: Number(token.accountType),
|
||||
activateStatus: Number(token.activateStatus),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { ExtensionRequestListItem } from "@/lib/extension-requests";
|
||||
import {
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
getRequestStatusLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
type ExtensionRequestsTableProps = {
|
||||
data: ExtensionRequestListItem[];
|
||||
};
|
||||
|
||||
export function ExtensionRequestsTable({ data }: ExtensionRequestsTableProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
<HeaderCell>신청ID</HeaderCell>
|
||||
<HeaderCell>마에스트로</HeaderCell>
|
||||
<HeaderCell>요청 계정</HeaderCell>
|
||||
<HeaderCell>현재 계정</HeaderCell>
|
||||
<HeaderCell>현재 상태</HeaderCell>
|
||||
<HeaderCell>신청일시</HeaderCell>
|
||||
<HeaderCell>만료일</HeaderCell>
|
||||
<HeaderCell>처리 상태</HeaderCell>
|
||||
<HeaderCell>처리</HeaderCell>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((request) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={request.maestroExtensionID}
|
||||
>
|
||||
<BodyCell>
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{request.maestroExtensionID}
|
||||
</span>
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "link",
|
||||
size: "sm",
|
||||
className: "h-auto p-0 font-medium",
|
||||
})}
|
||||
href={`/maestros/${request.maestroID}`}
|
||||
>
|
||||
{request.maestroName}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
MaestroID {request.maestroID}
|
||||
</p>
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
{getAccountTypeLabel(request.requestedAccountType)}
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
{getAccountTypeLabel(request.currentAccountType)}
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
{getActivateStatusLabel(request.activateStatus)}
|
||||
</BodyCell>
|
||||
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
|
||||
<BodyCell>
|
||||
{formatDate(request.availableActivateDateTime)}
|
||||
</BodyCell>
|
||||
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
|
||||
<BodyCell>
|
||||
<ExtensionRequestActions request={request} />
|
||||
</BodyCell>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-28 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={9}
|
||||
>
|
||||
조건에 맞는 연장 신청이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionRequestActions({
|
||||
request,
|
||||
}: {
|
||||
request: ExtensionRequestListItem;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [emailWarning, setEmailWarning] = useState(false);
|
||||
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
||||
|
||||
if (!isRequested && !emailWarning) {
|
||||
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
||||
}
|
||||
|
||||
async function submitAction(action: "approve" | "cancel") {
|
||||
const actionLabel = action === "approve" ? "승인" : "취소";
|
||||
if (!window.confirm(`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/extension-requests/${request.maestroExtensionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
}
|
||||
);
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
emailSent?: boolean;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (action === "approve" && body?.emailSent === false) {
|
||||
setEmailWarning(true);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "연장 신청 처리에 실패했습니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isRequested ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => void submitAction("approve")}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
승인
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => void submitAction("cancel")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">처리 완료</span>
|
||||
)}
|
||||
{emailWarning ? (
|
||||
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
|
||||
이메일 발송 실패 — 수동 확인 필요
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function BodyCell({ children }: { children: React.ReactNode }) {
|
||||
return <td className="h-12 px-3 align-middle">{children}</td>;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type ColumnDef,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { MaestroListItem } from "@/lib/maestros";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
type MaestrosTableProps = {
|
||||
data: MaestroListItem[];
|
||||
};
|
||||
|
||||
export function MaestrosTable({ data }: MaestrosTableProps) {
|
||||
const router = useRouter();
|
||||
const columns = useMemo<ColumnDef<MaestroListItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "maestroID",
|
||||
header: "ID",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.maestroID}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "이름",
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-32">
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground md:hidden">
|
||||
{row.original.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "이메일",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.email}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "accountType",
|
||||
header: "계정 유형",
|
||||
cell: ({ row }) => getAccountTypeLabel(row.original.accountType),
|
||||
},
|
||||
{
|
||||
accessorKey: "activateStatus",
|
||||
header: "상태",
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-md border px-2 text-xs font-medium",
|
||||
getActivateStatusBadgeClass(row.original)
|
||||
)}
|
||||
>
|
||||
{getActivateStatusLabel(row.original.activateStatus)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "playerCount",
|
||||
header: "학생 수",
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.playerCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "acceptClausesDateTime",
|
||||
header: "가입일",
|
||||
cell: ({ row }) => formatDate(row.original.acceptClausesDateTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "availableActivateDateTime",
|
||||
header: "만료일",
|
||||
cell: ({ row }) => formatDate(row.original.availableActivateDateTime),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// TanStack Table manages internal functions that React Compiler should not memoize.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr className="border-b" key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
aria-label={`${row.original.name} 마에스트로 상세 보기`}
|
||||
className="cursor-pointer border-b transition-colors last:border-b-0 hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
key={row.id}
|
||||
onClick={() => {
|
||||
router.push(`/maestros/${row.original.maestroID}`);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
router.push(`/maestros/${row.original.maestroID}`);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td className="h-12 px-3 align-middle" key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-28 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={columns.length}
|
||||
>
|
||||
조건에 맞는 마에스트로가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getActivateStatusBadgeClass(maestro: MaestroListItem) {
|
||||
if (
|
||||
maestro.activateStatus === 2 &&
|
||||
isBeforeToday(maestro.availableActivateDateTime)
|
||||
) {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
}
|
||||
|
||||
if (maestro.activateStatus === 2) {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
|
||||
if (maestro.activateStatus === 100) {
|
||||
return "border-muted bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
|
||||
function isBeforeToday(value: string) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDate = new Date(date);
|
||||
const today = new Date();
|
||||
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return targetDate.getTime() < today.getTime();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { UpgradeRequestListItem } from "@/lib/upgrade-requests";
|
||||
import {
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getActivateStatusLabel,
|
||||
getRequestStatusLabel,
|
||||
getTrialAccountTypeLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
type UpgradeRequestsTableProps = {
|
||||
data: UpgradeRequestListItem[];
|
||||
};
|
||||
|
||||
export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-background">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1080px] border-collapse text-sm">
|
||||
<thead className="bg-muted/60">
|
||||
<tr className="border-b">
|
||||
<HeaderCell>신청ID</HeaderCell>
|
||||
<HeaderCell>마에스트로</HeaderCell>
|
||||
<HeaderCell>현재 상태</HeaderCell>
|
||||
<HeaderCell>현재 계정</HeaderCell>
|
||||
<HeaderCell>요청 계정</HeaderCell>
|
||||
<HeaderCell>추가 금액</HeaderCell>
|
||||
<HeaderCell>신청일시</HeaderCell>
|
||||
<HeaderCell>만료일</HeaderCell>
|
||||
<HeaderCell>처리 상태</HeaderCell>
|
||||
<HeaderCell>처리</HeaderCell>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((request) => (
|
||||
<tr
|
||||
className="border-b last:border-b-0"
|
||||
key={request.maestroUpgradeID}
|
||||
>
|
||||
<BodyCell>
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{request.maestroUpgradeID}
|
||||
</span>
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "link",
|
||||
size: "sm",
|
||||
className: "h-auto p-0 font-medium",
|
||||
})}
|
||||
href={`/maestros/${request.maestroID}`}
|
||||
>
|
||||
{request.maestroName}
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
MaestroID {request.maestroID}
|
||||
</p>
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
{getActivateStatusLabel(request.registeredActivateStatus)}
|
||||
</BodyCell>
|
||||
<BodyCell>{getRegisteredAccountLabel(request)}</BodyCell>
|
||||
<BodyCell>
|
||||
{getAccountTypeLabel(request.requestedAccountType)}
|
||||
</BodyCell>
|
||||
<BodyCell>
|
||||
<span className="tabular-nums">
|
||||
{request.additionalPrice.toLocaleString()}원
|
||||
</span>
|
||||
</BodyCell>
|
||||
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
|
||||
<BodyCell>
|
||||
{formatDate(request.availableActivateDateTime)}
|
||||
</BodyCell>
|
||||
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
|
||||
<BodyCell>
|
||||
<UpgradeRequestActions request={request} />
|
||||
</BodyCell>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-28 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={10}
|
||||
>
|
||||
조건에 맞는 업그레이드 신청이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpgradeRequestActions({
|
||||
request,
|
||||
}: {
|
||||
request: UpgradeRequestListItem;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [emailWarning, setEmailWarning] = useState(false);
|
||||
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
|
||||
|
||||
if (!isRequested && !emailWarning) {
|
||||
return <span className="text-xs text-muted-foreground">처리 완료</span>;
|
||||
}
|
||||
|
||||
async function submitAction(action: "approve" | "reject") {
|
||||
const actionLabel = action === "approve" ? "승인" : "거절";
|
||||
if (!window.confirm(`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/upgrade-requests/${request.maestroUpgradeID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
}
|
||||
);
|
||||
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
emailSent?: boolean;
|
||||
} | null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.message ?? "업그레이드 신청 처리에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (action === "approve" && body?.emailSent === false) {
|
||||
setEmailWarning(true);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "업그레이드 신청 처리에 실패했습니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isRequested ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => void submitAction("approve")}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
승인
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={() => void submitAction("reject")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
거절
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">처리 완료</span>
|
||||
)}
|
||||
{emailWarning ? (
|
||||
<p className="max-w-56 text-xs text-amber-600 dark:text-amber-400">
|
||||
이메일 발송 실패 — 수동 확인 필요
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRegisteredAccountLabel(request: UpgradeRequestListItem) {
|
||||
if (request.registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
|
||||
return getTrialAccountTypeLabel(request.registeredAccountType);
|
||||
}
|
||||
|
||||
return getAccountTypeLabel(request.registeredAccountType);
|
||||
}
|
||||
|
||||
function HeaderCell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function BodyCell({ children }: { children: React.ReactNode }) {
|
||||
return <td className="h-12 px-3 align-middle">{children}</td>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type AutoSubmitSelectProps = ComponentProps<"select">;
|
||||
|
||||
export function AutoSubmitSelect({
|
||||
onChange,
|
||||
...props
|
||||
}: AutoSubmitSelectProps) {
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
onChange={(event) => {
|
||||
onChange?.(event);
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background text-foreground hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
|
||||
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
|
||||
chocoadmin-stage:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: chocoadmin-stage:latest
|
||||
container_name: chocoadmin-stage
|
||||
expose:
|
||||
- "3000"
|
||||
env_file:
|
||||
- .env.stage
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
APP_ENV: stage
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
TZ: Asia/Seoul
|
||||
networks:
|
||||
- proxy-network
|
||||
restart: unless-stopped
|
||||
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
|
||||
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
proxy-network:
|
||||
external: true
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 stage(chocoadmin-stage)와 겹치면 안 된다.
|
||||
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
|
||||
chocoadmin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: chocoadmin:latest
|
||||
container_name: chocoadmin
|
||||
expose:
|
||||
- "3000"
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
APP_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
TZ: Asia/Seoul
|
||||
networks:
|
||||
- proxy-network
|
||||
restart: unless-stopped
|
||||
# Synology 기본 db 로그 드라이버는 대량 로그 버스트 시 메시지를 드롭한다.
|
||||
# json-file(blocking 모드)은 무손실 — 표/SQL 로그 잘림 방지.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
proxy-network:
|
||||
external: true
|
||||
@@ -0,0 +1,354 @@
|
||||
# 기존 관리자 비즈니스 규칙
|
||||
|
||||
작성일: 2026-06-09
|
||||
|
||||
## 1. 개요
|
||||
|
||||
이 문서는 기존 `mouse-typing` 관리자 기능을 조사해 신규 `chocoadmin`에서 재현해야 할 비즈니스 규칙을 정리한 것이다.
|
||||
|
||||
조사 대상:
|
||||
|
||||
- 기존 서비스: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
- 관리자 화면: `src/web/admin/`
|
||||
- 관리자 서버: `src/web/server/admin/`
|
||||
- 공통 로그 유틸: `src/web/server/lib/maestro_log.php`
|
||||
- 계정 표시/계산 유틸: `src/web/js/lib/account_info.js`, `src/web/js/maestro_available_date.js`
|
||||
|
||||
중요 결론:
|
||||
|
||||
- 관리자 비밀번호는 MariaDB `PASSWORD()` 함수로 검증한다.
|
||||
- 연장 승인은 기존 만료일 기준 1년 연장하되, 만료된 계정은 오늘 기준 1년 연장한다.
|
||||
- 기존 연장 승인 서버는 날짜를 직접 계산하지 않고 화면에서 전달한 `new_available_date`를 저장한다.
|
||||
- 업그레이드 승인은 `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`만 변경하고 `PlayerCount`는 변경하지 않는다.
|
||||
- 기존 관리자에는 연장 취소/업그레이드 거절 버튼이 없다. 승인 처리 중 같은 마에스트로의 다른 요청을 `Status=2`로 닫는 동작만 있다.
|
||||
- 기존 변경 처리에는 명시적 트랜잭션이 없다.
|
||||
|
||||
## 2. 기존 관리자 파일 맵
|
||||
|
||||
| 기존 화면 | 서버 파일 | 역할 | 신규 `chocoadmin` 매핑 |
|
||||
|---|---|---|---|
|
||||
| `admin/login.html` | `server/admin/login.php` | 관리자 로그인 | `/login`, NextAuth Credentials Provider |
|
||||
| `admin/admin_all_list.html` | `server/admin/maestro_all_list.php` | 전체 마에스트로 목록 조회 | `/maestros`, `GET /api/maestros` |
|
||||
| `admin/admin_register_list.html` | `server/admin/maestro_registered_list.php`, `server/admin/register_maestro.php` | 미승인 마에스트로 조회 및 등록 승인 | MVP 외 또는 상세 보조 규칙 |
|
||||
| `admin/admin_extension_list.html` | `server/admin/maestro_extension_list.php`, `server/admin/extension_maestro.php` | 연장 요청 조회 및 승인 | `/extension-requests`, `PATCH /api/extension-requests/[id]` |
|
||||
| `admin/admin_upgrade_list.html` | `server/admin/maestro_upgrade_list.php`, `server/admin/upgrade_maestro.php` | 업그레이드 요청 조회 및 승인 | `/upgrade-requests`, `PATCH /api/upgrade-requests/[id]` |
|
||||
| `admin/admin_expiration_list.html` | 없음 | 정적 골격만 존재, 실제 조회 로직 없음 | MVP 제외 |
|
||||
| `admin/admin_send_email.html` | `server/mail/send_mail_test.php` | 테스트 메일 발송 화면 | MVP 제외 |
|
||||
| `admin/admin_header.html` | 없음 | 관리자 메뉴 링크 | 공통 사이드바/헤더 |
|
||||
|
||||
## 3. 상태값 코드표
|
||||
|
||||
### `maestro.AccountType`
|
||||
|
||||
| 값 | 의미 | 정원 | 금액 |
|
||||
|---:|---|---:|---:|
|
||||
| 1 | 20명 | 20 | 10,000 |
|
||||
| 2 | 50명 | 50 | 20,000 |
|
||||
| 3 | 100명 | 100 | 30,000 |
|
||||
| 4 | 500명 | 500 | 40,000 |
|
||||
| 5 | 1,000명 | 1,000 | 50,000 |
|
||||
| 100 | 학생체험 | - | - |
|
||||
| 101 | 마에체험 | - | - |
|
||||
|
||||
### `maestro.ActivateStatus`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 0 | 미승인 / 입금대기 | 가입 후 관리자 승인 전 |
|
||||
| 1 | 체험 | 체험 계정 |
|
||||
| 2 | 활성화 | 유료 활성 계정 |
|
||||
| 100 | 등록 취소 / 환불 | 등록 취소 또는 환불 상태 |
|
||||
|
||||
### `maestro_extension.Status`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 1 | 요청 | 연장 요청 |
|
||||
| 2 | 취소 | 닫힌 요청 |
|
||||
| 3 | 업그레이드 적용 | 코드상 공통 표시 문구이나 실제 의미는 연장 적용 |
|
||||
|
||||
### `maestro_upgrade.Status`
|
||||
|
||||
| 값 | 기존 표시 | 의미 |
|
||||
|---:|---|---|
|
||||
| 1 | 요청 | 업그레이드 요청 |
|
||||
| 2 | 취소 | 닫힌 요청 |
|
||||
| 3 | 업그레이드 적용 | 업그레이드 적용 |
|
||||
|
||||
## 4. 로그인/관리자 인증 규칙
|
||||
|
||||
기존 흐름:
|
||||
|
||||
1. `login.html`에서 `admin_name`, `password`를 `server/admin/login.php`로 POST한다.
|
||||
2. `get_admin_id($adminName)`에서 `admin.Name = ?` 조건으로 관리자 존재 여부를 먼저 확인한다.
|
||||
3. `login($adminName, $password)`에서 `admin.Name = ? AND admin.Password = PASSWORD(?)` 조건으로 검증한다.
|
||||
4. 응답 데이터로 `adminID`, `accountType`, `activateStatus`를 반환한다.
|
||||
|
||||
실패 조건:
|
||||
|
||||
| 조건 | error_code | message |
|
||||
|---|---|---|
|
||||
| `Name`에 해당하는 관리자 없음 | `not_registered_id` | 등록되지 않은 서비스 관리자 아이디입니다. |
|
||||
| 비밀번호 검증 결과 없음 | `no_data` 또는 `wrong_password` | 로그인하지 못했습니다. / 비밀번호가 틀렸습니다. |
|
||||
| `ActivateStatus === 0` | `not_activated` | 서비스 관리자 계정이 아직 활성화되지 않았습니다. |
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 기존 계정 호환을 위해 최초 구현은 MariaDB `PASSWORD()` 함수를 이용해 검증한다.
|
||||
- NextAuth 세션에는 최소 `adminID`, `accountType`, `activateStatus`를 포함한다.
|
||||
- `ActivateStatus = 0` 관리자는 로그인시키지 않는다.
|
||||
- `PASSWORD()`는 보안상 낡은 방식이므로, 해시 업그레이드는 별도 마이그레이션 과제로 분리한다.
|
||||
|
||||
## 5. 마에스트로 목록 조회 규칙
|
||||
|
||||
### 전체 목록
|
||||
|
||||
기존 서버: `server/admin/maestro_all_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro`
|
||||
- 컬럼: `MaestroID`, `Name`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `AcceptClausesDateTime`, `PlayerCount`
|
||||
- 정렬: `MaestroID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `Name LIKE CONCAT('%', ?, '%')`
|
||||
- 정렬: `MaestroID DESC`
|
||||
- 주의: 기존 검색 쿼리는 `AvailableActivateDateTime`을 조회하지 않는데 화면은 해당 값을 사용한다. 신규 구현에서는 전체/검색 결과 모두 동일 컬럼을 반환해야 한다.
|
||||
|
||||
기존 화면 표시:
|
||||
|
||||
- 상태 텍스트는 `accountStatus(accountType, activateStatus)`로 계산한다.
|
||||
- `AvailableActivateDateTime`이 `0`으로 시작하면 빈 문자열로 표시한다.
|
||||
- 비활성화 버튼은 disabled 상태이며 동작하지 않는다.
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- `Name`, `Email`, `MaestroID` 검색을 지원하되 기존 기본 정렬은 `MaestroID DESC`로 시작한다.
|
||||
- 전체/검색 결과의 반환 컬럼 차이를 만들지 않는다.
|
||||
|
||||
### 미승인 등록 목록
|
||||
|
||||
기존 서버: `server/admin/maestro_registered_list.php`
|
||||
|
||||
- 테이블: `maestro`
|
||||
- 조건: `ActivateStatus = 0`
|
||||
- 검색 조건: `Name LIKE CONCAT('%', ?, '%') AND ActivateStatus = 0`
|
||||
- 컬럼: `MaestroID`, `Name`, `AccountType`, `AcceptClausesDateTime`
|
||||
- 정렬: `MaestroID DESC`
|
||||
|
||||
## 6. 등록/활성화 처리 규칙
|
||||
|
||||
기존 서버: `server/admin/register_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.ActivateStatus = 1`로 변경한다.
|
||||
2. `maestro.AvailableActivateDateTime = '2022-07-31 23:59:59'`로 변경한다.
|
||||
3. `player`에 테스트 플레이어 `Name='마에스트로'`, `EnterCode=''`, `AccountType=1`을 추가한다.
|
||||
4. 추가된 테스트 플레이어를 `maestro.MaestroTestID`에 저장한다.
|
||||
5. `app.Status = 1`인 앱을 모두 `active_app`에 추가한다.
|
||||
6. 샘플 학생 4명을 `player.AccountType = 0`으로 추가한다.
|
||||
7. `player.AccountType = 0` 학생 수를 세어 `maestro.PlayerCount`에 저장한다.
|
||||
8. 등록 완료 메일을 발송한다.
|
||||
9. `maestro_log`에 `register_maestro` 로그를 기록한다.
|
||||
|
||||
주의:
|
||||
|
||||
- 코드에는 `DATE_ADD(NOW(), INTERVAL 1 YEAR)`와 `DATE_ADD(NOW(), INTERVAL 14 DAY)`가 주석으로 남아 있지만 실제 실행 쿼리는 고정일 `2022-07-31 23:59:59`다.
|
||||
- 현재 MVP의 핵심 변경 기능은 연장/업그레이드이므로 등록 승인은 별도 범위로 둘 수 있다.
|
||||
|
||||
## 7. 연장 신청 조회/승인 규칙
|
||||
|
||||
### 요청 생성
|
||||
|
||||
기존 서버: `server/maestro/request_extension_maestro.php`
|
||||
|
||||
- `maestro_extension`에 `AccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
|
||||
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
|
||||
- INSERT 후 `MaestroID` 기준 전체 연장 요청 수가 1개 이상인지 단순 확인한다.
|
||||
- 입금 안내 메일을 발송한다.
|
||||
|
||||
### 관리자 목록 조회
|
||||
|
||||
기존 서버: `server/admin/maestro_extension_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro_extension E`, `maestro M`
|
||||
- 조건: `E.Status < 2 AND E.MaestroID = M.MaestroID`
|
||||
- 컬럼: `E.MaestroExtensionID`, `E.MaestroID`, `M.Name`, `E.AccountType`, `E.RequestedDateTime`, `M.AvailableActivateDateTime`, `E.Status`
|
||||
- 정렬: `E.MaestroExtensionID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND E.MaestroID = M.MaestroID`
|
||||
- 주의: 검색 쿼리에는 `E.Status < 2` 조건이 없다. 신규 구현에서는 기본 정책에 맞게 상태 필터를 명시적으로 제공해야 한다.
|
||||
|
||||
### 관리자 승인
|
||||
|
||||
기존 화면: `admin/admin_extension_list.html`
|
||||
|
||||
- `availableActivateDateTime`의 날짜 부분을 기준으로 새 만료일을 계산한다.
|
||||
- 계산 유틸: `src/web/js/maestro_available_date.js`
|
||||
- 현재 만료일이 지났으면 오늘 기준 1년 후 날짜를 사용한다.
|
||||
- 현재 만료일이 지나지 않았으면 기존 만료일 기준 1년 후 날짜를 사용한다.
|
||||
- 서버에는 `new_available_date` 문자열을 POST한다.
|
||||
|
||||
기존 서버: `server/admin/extension_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime = DATE_FORMAT(?, '%Y-%m-%d')`로 변경한다.
|
||||
2. 같은 `MaestroID`의 `maestro_extension.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
|
||||
3. 선택한 `MaestroExtensionID` 요청을 `Status = 3`으로 변경한다.
|
||||
4. 연장 완료 메일을 발송한다.
|
||||
5. `maestro_log`에 `extension_maestro` 로그를 기록한다.
|
||||
|
||||
로그:
|
||||
|
||||
- `Type`: `extension_maestro`
|
||||
- `Remark`: `{maestro_name}({registered_account_type})`
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 날짜 계산은 클라이언트가 아니라 서버에서 수행한다.
|
||||
- 기존 규칙과 동일하게 만료 전이면 기존 만료일 기준 +1년, 만료 후면 현재일 기준 +1년으로 계산한다.
|
||||
- `AvailableActivateDateTime` 저장 시 기존 서버는 날짜만 저장하므로 시간은 DB 기본 파싱 결과를 고려해야 한다. 신규 구현에서는 일관성을 위해 `YYYY-MM-DD 00:00:00` 또는 기존 호환 형식을 명시한다.
|
||||
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
|
||||
- 트랜잭션 안에서 `maestro`, `maestro_extension`, `maestro_log`를 함께 처리한다.
|
||||
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
|
||||
|
||||
## 8. 업그레이드 신청 조회/승인 규칙
|
||||
|
||||
### 요청 생성
|
||||
|
||||
기존 서버: `server/maestro/request_upgrade_maestro.php`
|
||||
|
||||
- `maestro_upgrade`에 `RegisteredActivateStatus`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
|
||||
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
|
||||
- INSERT 후 `MaestroID` 기준 전체 업그레이드 요청 수가 1개 이상인지 단순 확인한다.
|
||||
- 체험 계정이면 체험 업그레이드 입금 안내 메일, 그 외에는 일반 업그레이드 입금 안내 메일을 발송한다.
|
||||
|
||||
### 관리자 목록 조회
|
||||
|
||||
기존 서버: `server/admin/maestro_upgrade_list.php`
|
||||
|
||||
검색어가 없을 때:
|
||||
|
||||
- 테이블: `maestro_upgrade U`, `maestro M`
|
||||
- 조건: `U.Status < 2 AND U.MaestroID = M.MaestroID`
|
||||
- 컬럼: `U.MaestroUpgradeID`, `U.MaestroID`, `M.Name`, `U.RegisteredActivateStatus`, `U.RegisteredAccountType`, `U.RequestedAccountType`, `U.RequestedDateTime`, `M.AvailableActivateDateTime`, `U.Status`
|
||||
- 정렬: `U.MaestroUpgradeID DESC`
|
||||
|
||||
검색어가 있을 때:
|
||||
|
||||
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND U.MaestroID = M.MaestroID`
|
||||
- 주의: 검색 쿼리에는 `U.Status < 2` 조건이 없다. 신규 구현에서는 상태 필터를 명시적으로 제공해야 한다.
|
||||
|
||||
### 관리자 승인
|
||||
|
||||
기존 화면: `admin/admin_upgrade_list.html`
|
||||
|
||||
- `Status = 1`인 요청만 승인 버튼을 표시한다.
|
||||
- 체험 계정(`RegisteredActivateStatus = 1`)은 기존 요금제를 `trialAccountType`으로 표시하고 추가 금액은 요청 요금제 전체 금액으로 계산한다.
|
||||
- 그 외 계정은 추가 금액을 `RequestedAccountType` 금액 - `RegisteredAccountType` 금액으로 계산한다.
|
||||
|
||||
기존 서버: `server/admin/upgrade_maestro.php`
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `maestro.AccountType = upgrade_account_type`으로 변경한다.
|
||||
2. `maestro.ActivateStatus = 2`로 변경한다.
|
||||
3. `maestro.AvailableActivateDateTime = DATE_ADD(NOW(), INTERVAL 1 YEAR)`로 변경한다.
|
||||
4. 같은 `MaestroID`의 `maestro_upgrade.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
|
||||
5. 선택한 `MaestroUpgradeID` 요청을 `Status = 3`으로 변경한다.
|
||||
6. 체험 계정이면 체험 업그레이드 완료 메일, 그 외에는 일반 업그레이드 완료 메일을 발송한다.
|
||||
7. `maestro_log`에 `upgrade_maestro` 로그를 기록한다.
|
||||
|
||||
로그:
|
||||
|
||||
- `Type`: `upgrade_maestro`
|
||||
- `Remark`: `{registered_account_type} -> {upgrade_account_type}`
|
||||
|
||||
확정 사항:
|
||||
|
||||
- `PlayerCount`는 변경하지 않는다.
|
||||
- `AvailableActivateDateTime`은 기존 만료일 기준이 아니라 승인 시점 `NOW()` 기준 1년 후로 설정한다.
|
||||
|
||||
신규 구현 규칙:
|
||||
|
||||
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
|
||||
- 트랜잭션 안에서 `maestro`, `maestro_upgrade`, `maestro_log`를 함께 처리한다.
|
||||
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
|
||||
- `PlayerCount`는 업그레이드 승인에서 변경하지 않는다.
|
||||
|
||||
## 9. 취소/거절 규칙
|
||||
|
||||
기존 관리자 UI/서버에서 확인된 사실:
|
||||
|
||||
- 연장 신청에 대한 별도 취소 버튼이나 취소 API는 없다.
|
||||
- 업그레이드 신청에 대한 별도 거절 버튼이나 거절 API는 없다.
|
||||
- `Status = 2`는 승인 처리 중 같은 마에스트로의 다른 미처리 요청을 닫기 위해 사용된다.
|
||||
- 화면 유틸 `status()`는 `Status = 2`를 "취소"로 표시하지만, 관리자가 명시적으로 취소/거절하는 흐름은 구현되어 있지 않다.
|
||||
|
||||
신규 구현 판단:
|
||||
|
||||
- `docs/plan/project-plan.md`의 취소/거절 기능은 기존 관리자 재현이 아니라 신규 보완 기능이다.
|
||||
- 취소/거절 API를 만들 경우 `Status = 1`인 단일 요청만 `Status = 2`로 변경하고 `maestro_log`에 별도 로그를 남기는 방식으로 정의해야 한다.
|
||||
- 기존 `maestro_log.Type`에는 취소/거절 전용 타입이 없으므로 신규 타입을 도입할지 기존 타입에 `Remark`로 표현할지 결정이 필요하다.
|
||||
|
||||
권장 신규 로그 타입:
|
||||
|
||||
- `cancel_extension_maestro`
|
||||
- `reject_upgrade_maestro`
|
||||
|
||||
## 10. `maestro_log` 기록 규칙
|
||||
|
||||
공통 함수: `src/web/server/lib/maestro_log.php`
|
||||
|
||||
```sql
|
||||
INSERT INTO maestro_log (Type, MaestroID, LogDateTime, Remark)
|
||||
VALUES (?, ?, NOW(), ?)
|
||||
```
|
||||
|
||||
확인된 로그 타입:
|
||||
|
||||
| Type | 발생 위치 | Remark 형식 |
|
||||
|---|---|---|
|
||||
| `add_maestro` | `server/maestro/add_maestro.php` | `{maestro_name} / {email} / {account_type}` |
|
||||
| `register_maestro` | `server/admin/register_maestro.php` | `{name} / {email}` |
|
||||
| `extension_maestro` | `server/admin/extension_maestro.php` | `{maestro_name}({registered_account_type})` |
|
||||
| `upgrade_maestro` | `server/admin/upgrade_maestro.php` | `{registered_account_type} -> {upgrade_account_type}` |
|
||||
| `update_maestro_name` | `server/maestro/update_maestro_info.php` | `{previous_name} -> {new_name}` |
|
||||
| `update_maestro_email` | `server/maestro/update_maestro_info.php` | `{previous_email} -> {new_email}` |
|
||||
|
||||
확인 결과:
|
||||
|
||||
- `update_maestro` 타입은 코드 검색에서 확인되지 않았다.
|
||||
- 로그에는 관리자 ID나 관리자 이름이 포함되지 않는다.
|
||||
- 로그 시간은 DB 서버의 `NOW()`를 사용한다.
|
||||
- `Remark` 컬럼은 `char(100)`이므로 신규 구현에서도 길이 초과에 주의한다.
|
||||
|
||||
## 11. 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
|
||||
|
||||
- 운영 DB 스키마는 변경하지 않는다.
|
||||
- 관리자 로그인은 기존 `admin.Name`과 `admin.Password = PASSWORD(?)` 호환 검증을 지원한다.
|
||||
- 모든 변경 API는 서버에서 현재 상태를 다시 조회하고 `Status = 1`인지 확인한다.
|
||||
- 승인/취소/거절 같은 변경 작업은 트랜잭션으로 묶는다.
|
||||
- 연장 승인은 서버에서 새 만료일을 계산한다.
|
||||
- 업그레이드 승인은 `PlayerCount`를 변경하지 않는다.
|
||||
- 연장/업그레이드 승인 시 같은 마에스트로의 다른 요청을 `Status = 2`로 닫고 선택 요청을 `Status = 3`으로 적용한다.
|
||||
- 기존 목록 검색 쿼리의 상태 필터 누락은 그대로 복제하지 말고 명시적 필터로 보완한다.
|
||||
- 메일 발송은 트랜잭션 커밋 이후 수행하는 것이 안전하다. 기존 코드는 DB 변경 후 메일을 발송한다.
|
||||
|
||||
## 12. 기존 코드에는 없지만 신규 구현에서 보완할 사항
|
||||
|
||||
- 명시적 트랜잭션: 기존 관리자 변경 로직에는 `BEGIN`, `COMMIT`, `ROLLBACK`이 없다.
|
||||
- 중복 처리 방지: 기존 승인 서버는 요청의 현재 `Status`를 먼저 검증하지 않는다.
|
||||
- 서버 사이드 날짜 계산: 기존 연장 승인은 클라이언트 계산값을 신뢰한다.
|
||||
- 검색 필터 일관성: 연장/업그레이드 검색 쿼리에 `Status < 2` 조건이 빠져 있다.
|
||||
- 목록 컬럼 일관성: 전체 마에스트로 검색 쿼리에 `AvailableActivateDateTime`이 빠져 있다.
|
||||
- 취소/거절 정의: 기존에는 별도 액션이 없으므로 신규 기능으로 명세화해야 한다.
|
||||
- 감사성 강화: 기존 `maestro_log`에는 관리자 식별자가 없으므로 세션 기반 감사 로그가 필요하면 별도 설계가 필요하다. 단, DB 스키마 변경 없이 구현하려면 `Remark`에 관리자 정보를 포함하는 방식만 가능하다.
|
||||
@@ -0,0 +1,223 @@
|
||||
# chocoadmin Deployment
|
||||
|
||||
## 1. Environment Files
|
||||
|
||||
Create environment files on the Synology NAS. Do not commit real `.env.*` files.
|
||||
|
||||
Stage file path:
|
||||
|
||||
```bash
|
||||
/volume1/docker/service/jinaju/chocoadmin/.env.stage
|
||||
```
|
||||
|
||||
Stage example:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
|
||||
AUTH_SECRET="replace-with-stage-secret"
|
||||
NEXTAUTH_SECRET="replace-with-stage-secret"
|
||||
AUTH_URL="https://chocoadmin-stage.jisangs.com"
|
||||
NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
|
||||
```
|
||||
|
||||
Production file path:
|
||||
|
||||
```bash
|
||||
/volume1/docker/service/jinaju/chocoadmin/.env.production
|
||||
```
|
||||
|
||||
Production example:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@chocomae.jinaju.com:3306/chocomae"
|
||||
AUTH_SECRET="replace-with-production-secret"
|
||||
NEXTAUTH_SECRET="replace-with-production-secret"
|
||||
AUTH_URL="https://chocoadmin.jinaju.com"
|
||||
NEXTAUTH_URL="https://chocoadmin.jinaju.com"
|
||||
```
|
||||
|
||||
`AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
|
||||
|
||||
`APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`chocoadmin-${APP_ENV}.session-token`), which keeps production and stage cookies separate even if they share a browser.
|
||||
|
||||
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
|
||||
|
||||
Generate a secret on the NAS:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## 2. Synology Stage Deployment
|
||||
|
||||
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/service/jinaju/chocoadmin-stage
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
|
||||
|
||||
Verify or create the network:
|
||||
|
||||
```bash
|
||||
docker network inspect proxy-network >/dev/null 2>&1 || docker network create proxy-network
|
||||
```
|
||||
|
||||
Start or update stage:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.stage.yml up -d --build
|
||||
docker compose -f docker-compose.stage.yml ps
|
||||
docker compose -f docker-compose.stage.yml logs -f
|
||||
```
|
||||
|
||||
If a service was renamed in the compose file, add `--remove-orphans` once to remove the stale container.
|
||||
|
||||
After redeployment, reload NPM to flush its upstream DNS cache:
|
||||
|
||||
```bash
|
||||
docker exec npm nginx -s reload
|
||||
```
|
||||
|
||||
`docker-compose.stage.yml` exposes container port `3000` to `proxy-network` instead of binding host port `3000`, because host port `3000` may already be used by another service such as Gitea.
|
||||
|
||||
Nginx Proxy Manager settings:
|
||||
|
||||
- Scheme: `http`
|
||||
- Forward Hostname / IP: `chocoadmin-stage`
|
||||
- Forward Port: `3000`
|
||||
- Websockets Support: enabled
|
||||
- SSL: enabled
|
||||
- Force SSL: enabled
|
||||
|
||||
Stage URL:
|
||||
|
||||
```text
|
||||
https://chocoadmin-stage.jisangs.com
|
||||
```
|
||||
|
||||
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
|
||||
|
||||
## 3. Production Deployment
|
||||
|
||||
Production uses `docker-compose.yml` and reads `.env.production` by default:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/service/jinaju/chocoadmin
|
||||
git pull --ff-only
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
docker compose logs -f chocoadmin
|
||||
```
|
||||
|
||||
After redeployment, reload NPM:
|
||||
|
||||
```bash
|
||||
docker exec npm nginx -s reload
|
||||
```
|
||||
|
||||
Nginx Proxy Manager settings:
|
||||
|
||||
- Scheme: `http`
|
||||
- Forward Hostname / IP: `chocoadmin`
|
||||
- Forward Port: `3000`
|
||||
- Websockets Support: enabled
|
||||
- SSL: enabled
|
||||
- Force SSL: enabled
|
||||
|
||||
Production URL:
|
||||
|
||||
```text
|
||||
https://chocoadmin.jinaju.com
|
||||
```
|
||||
|
||||
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from the compose `env_file`.
|
||||
|
||||
## 4. AWS EC2 Security Group
|
||||
|
||||
Allow MariaDB only from the Synology NAS public IP.
|
||||
|
||||
- Type: `MYSQL/Aurora`
|
||||
- Protocol: `TCP`
|
||||
- Port: `3306`
|
||||
- Source: `NAS_PUBLIC_IP/32`
|
||||
- Description: `chocoadmin Synology NAS`
|
||||
|
||||
Do not open `3306` to `0.0.0.0/0`.
|
||||
|
||||
## 5. Read-Only Rehearsal
|
||||
|
||||
Before enabling approval/rejection operations against production DB:
|
||||
|
||||
1. Create or use a DB account with read-only permissions.
|
||||
2. Set `DATABASE_URL` in `.env.production` to that read-only account.
|
||||
3. Start the container.
|
||||
4. Verify login, maestro list, extension request list, and upgrade request list.
|
||||
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
|
||||
|
||||
## 6. Smoke Checks
|
||||
|
||||
After each deployment:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.stage.yml ps # stage
|
||||
docker compose ps # production
|
||||
```
|
||||
|
||||
Verify these routes in the browser:
|
||||
|
||||
- `/login`
|
||||
- `/maestros`
|
||||
- `/extension-requests`
|
||||
- `/upgrade-requests`
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### ERR_TOO_MANY_REDIRECTS after login
|
||||
|
||||
Middleware redirects to `/login`, login page redirects back to `/` — infinite loop. Work through this checklist in order:
|
||||
|
||||
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
|
||||
2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request.
|
||||
3. **Check cookie name consistency** — `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `chocoadmin-${APP_ENV}.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session.
|
||||
4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP.
|
||||
5. **Clear browser cookies** for the domain, then test in a private window.
|
||||
6. **Recreate the container** after changing `.env.*`.
|
||||
|
||||
### Production and stage traffic mixing
|
||||
|
||||
Symptom: logging into `chocoadmin.jinaju.com` shows the stage UI, or requests hit both containers interchangeably.
|
||||
|
||||
Cause: Docker registers each `services:` key as a DNS alias in `proxy-network`. If stage and production share the same service name, NPM's upstream resolves to both containers via round-robin.
|
||||
|
||||
Check the network:
|
||||
|
||||
```bash
|
||||
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||
```
|
||||
|
||||
Expected: `chocoadmin` and `chocoadmin-stage` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias.
|
||||
|
||||
Fix: ensure `docker-compose.stage.yml` has `services: chocoadmin-stage:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM.
|
||||
|
||||
### Failed to find Server Action after redeployment
|
||||
|
||||
Symptom: clicking a button (e.g. logout) returns a 404 or `Failed to find Server Action` error after deploying a new build.
|
||||
|
||||
Cause: the action was defined as an inline `"use server"` closure. Closures get a new action ID on every build. The browser cached the old ID.
|
||||
|
||||
Fix: force-reload the page (`Cmd+Shift+R` / `Ctrl+Shift+R`) to discard the cached page with stale action IDs. If the problem recurs after every deployment, the action must be extracted to a module-level named export in a separate `actions.ts` file.
|
||||
|
||||
### Container starts but immediately exits
|
||||
|
||||
```bash
|
||||
docker compose logs chocoadmin
|
||||
```
|
||||
|
||||
Common causes:
|
||||
|
||||
- Missing `DATABASE_URL` or malformed connection string.
|
||||
- `AUTH_SECRET` not set — NextAuth throws on startup.
|
||||
- Port already allocated — check if another service uses host port 3000. Both compose files use `expose` (not `ports`) for port 3000, so this should not happen unless the compose file was modified.
|
||||
@@ -0,0 +1,285 @@
|
||||
# Phase 0 작업 계획
|
||||
|
||||
## 1. 목적
|
||||
|
||||
`docs/plan/project-plan.md`의 `9. 개발 단계 계획 > Phase 0. 기존 관리자 코드 조사`를 수행하기 위한 실행 계획이다.
|
||||
|
||||
Phase 0의 목표는 기존 `mouse-typing` 관리자 기능의 비즈니스 규칙을 확인하고, 신규 `chocoadmin`에서 동일하게 재현해야 할 규칙을 `docs/business-rules.md`에 문서화하는 것이다.
|
||||
|
||||
## 2. 조사 대상
|
||||
|
||||
기존 서비스 경로:
|
||||
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
|
||||
우선 조사 디렉터리:
|
||||
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin/`
|
||||
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/`
|
||||
|
||||
주요 서버 파일:
|
||||
|
||||
- `login.php`
|
||||
- `maestro_all_list.php`
|
||||
- `maestro_registered_list.php`
|
||||
- `maestro_extension_list.php`
|
||||
- `extension_maestro.php`
|
||||
- `maestro_upgrade_list.php`
|
||||
- `upgrade_maestro.php`
|
||||
- `register_maestro.php`
|
||||
- `activate_stage.php`
|
||||
|
||||
주요 화면 파일:
|
||||
|
||||
- `login.html`
|
||||
- `admin_all_list.html`
|
||||
- `admin_register_list.html`
|
||||
- `admin_extension_list.html`
|
||||
- `admin_upgrade_list.html`
|
||||
- `admin_expiration_list.html`
|
||||
- `admin_header.html`
|
||||
|
||||
## 3. 확인해야 할 핵심 질문
|
||||
|
||||
### 3-1. 관리자 라우트와 기능 구조
|
||||
|
||||
- 관리자 화면별 HTML 파일과 서버 PHP 파일의 호출 관계를 확인한다.
|
||||
- 각 화면의 목록 조회 조건, 검색 조건, 정렬 기준, 상태 필터를 확인한다.
|
||||
- 신규 `chocoadmin`의 화면/API 구조와 직접 매핑 가능한 기능 목록을 정리한다.
|
||||
|
||||
산출물:
|
||||
|
||||
- 기존 화면 -> 기존 서버 파일 -> 신규 화면/API 매핑표
|
||||
|
||||
### 3-2. 연장 승인 규칙
|
||||
|
||||
- `extension_maestro.php`에서 연장 승인 처리 흐름을 확인한다.
|
||||
- `maestro_extension.Status` 변경값과 변경 조건을 확인한다.
|
||||
- `maestro.AvailableActivateDateTime` 계산 방식을 확인한다.
|
||||
- `maestro.ActivateStatus` 변경 여부와 값을 확인한다.
|
||||
- 실패/중복 처리 조건이 있는지 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- 계정 유형별 차이 없이 1년 연장
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 기존 코드에서 날짜 계산 로직이 `+1 year`, `+12 month`, 고정 일수 중 무엇인지 확인한다.
|
||||
- 만료일이 과거인 경우 기준일이 현재일인지 기존 만료일인지 확인한다.
|
||||
|
||||
### 3-3. 업그레이드 승인 규칙
|
||||
|
||||
- `upgrade_maestro.php`에서 업그레이드 승인 처리 흐름을 확인한다.
|
||||
- `maestro_upgrade.Status` 변경값과 변경 조건을 확인한다.
|
||||
- `maestro.AccountType` 변경 기준을 확인한다.
|
||||
- `maestro.PlayerCount`를 즉시 변경하는지 확인한다.
|
||||
- 거절 처리 시 변경되는 테이블과 로그를 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- `AccountType`만 변경
|
||||
- `PlayerCount`는 변경하지 않음
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 승인 SQL에 `PlayerCount` 업데이트가 포함되는지 확인한다.
|
||||
- 목록 조회 시 `RegisteredAccountType`, `RequestedAccountType`을 어떻게 표시하는지 확인한다.
|
||||
|
||||
### 3-4. 관리자 비밀번호 검증 방식
|
||||
|
||||
- `login.php`에서 `admin.Password` 검증 SQL을 확인한다.
|
||||
- MariaDB `PASSWORD()` 함수 사용 여부를 확인한다.
|
||||
- 세션에 저장되는 관리자 식별자와 권한 정보를 확인한다.
|
||||
- 실패 응답 형식과 비활성 관리자 처리 여부를 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 가정:
|
||||
|
||||
- MariaDB `PASSWORD()` 함수로 저장/검증
|
||||
|
||||
검증 기준:
|
||||
|
||||
- `WHERE Password = PASSWORD(?)` 형태인지 확인한다.
|
||||
- `ActivateStatus`, `AccountType` 같은 추가 조건이 있는지 확인한다.
|
||||
|
||||
### 3-5. `maestro_log` 기록 형식
|
||||
|
||||
- 관리자 변경 액션이 `maestro_log`에 기록되는 모든 지점을 찾는다.
|
||||
- `Type` 값 목록을 확인한다.
|
||||
- `Remark` 문자열 형식과 포함 정보, 날짜 형식을 확인한다.
|
||||
- 로그 작성 시 관리자 ID/이름이 포함되는지 확인한다.
|
||||
|
||||
현재 `docs/plan/project-plan.md`에 기록된 `Type` 후보:
|
||||
|
||||
- `add_maestro`
|
||||
- `update_maestro`
|
||||
- `register_maestro`
|
||||
- `update_maestro_name`
|
||||
- `extension_maestro`
|
||||
- `upgrade_maestro`
|
||||
- `update_maestro_email`
|
||||
|
||||
검증 기준:
|
||||
|
||||
- 중복 기재된 값과 실제 미사용 값을 정리한다.
|
||||
- 각 액션별 `Remark` 예시를 코드 기준으로 문서화한다.
|
||||
|
||||
## 4. 작업 순서
|
||||
|
||||
### Step 1. 파일 인벤토리 작성
|
||||
|
||||
- 관리자 HTML/PHP 파일 목록을 확정한다.
|
||||
- 각 파일의 역할을 한 줄로 요약한다.
|
||||
- AJAX 호출 URL, form action, query parameter를 찾는다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `docs/business-rules.md`에 "기존 관리자 파일 맵" 섹션 초안 작성
|
||||
|
||||
### Step 2. 로그인 로직 조사
|
||||
|
||||
- `login.html`과 `login.php`를 연결해 인증 흐름을 추적한다.
|
||||
- DB 조회 조건, 비밀번호 검증 방식, 세션 키를 기록한다.
|
||||
- 신규 NextAuth Credentials Provider에서 재현해야 할 조건을 정리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `admin.Password` 검증 방식 확정
|
||||
- 로그인 성공/실패 조건 문서화
|
||||
|
||||
### Step 3. 목록 조회 로직 조사
|
||||
|
||||
- 전체 마에스트로 목록, 등록 신청 목록, 연장 신청 목록, 업그레이드 신청 목록 SQL을 확인한다.
|
||||
- 조인 테이블, 상태 필터, 기본 정렬, 표시 컬럼을 정리한다.
|
||||
- 신규 API 쿼리 설계에 필요한 컬럼 목록을 분리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 신규 목록 API별 필수 SQL 조건 문서화
|
||||
|
||||
### Step 4. 등록/활성화 처리 조사
|
||||
|
||||
- `register_maestro.php`와 `activate_stage.php`의 상태 변경 로직을 확인한다.
|
||||
- `maestro.ActivateStatus`, `AvailableActivateDateTime`, `maestro_log` 처리 규칙을 기록한다.
|
||||
- MVP 범위에 직접 포함되지 않더라도 상세 화면과 로그 해석에 필요한 규칙을 정리한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 마에스트로 상태값 변경 규칙 문서화
|
||||
|
||||
### Step 5. 연장 승인/취소 처리 조사
|
||||
|
||||
- `maestro_extension_list.php`에서 목록 조회 조건을 확인한다.
|
||||
- `extension_maestro.php`에서 승인/취소 처리 SQL을 순서대로 기록한다.
|
||||
- 트랜잭션 사용 여부와 중복 처리 방지 조건을 확인한다.
|
||||
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 연장 승인 시 날짜 계산 기준 확정
|
||||
- 연장 취소 시 상태값과 로그 형식 확정
|
||||
|
||||
### Step 6. 업그레이드 승인/거절 처리 조사
|
||||
|
||||
- `maestro_upgrade_list.php`에서 목록 조회 조건을 확인한다.
|
||||
- `upgrade_maestro.php`에서 승인/거절 처리 SQL을 순서대로 기록한다.
|
||||
- `AccountType`, `PlayerCount`, `maestro_upgrade.Status`, `maestro_log` 변경 규칙을 확인한다.
|
||||
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 업그레이드 승인 시 변경 컬럼 확정
|
||||
- 업그레이드 거절 시 상태값과 로그 형식 확정
|
||||
|
||||
### Step 7. 비즈니스 규칙 문서 작성
|
||||
|
||||
`docs/business-rules.md`를 생성하고 다음 구조로 정리한다.
|
||||
|
||||
- 개요
|
||||
- 기존 관리자 파일 맵
|
||||
- 상태값 코드표
|
||||
- 로그인/관리자 인증 규칙
|
||||
- 마에스트로 목록 조회 규칙
|
||||
- 등록/활성화 처리 규칙
|
||||
- 연장 신청 조회/승인/취소 규칙
|
||||
- 업그레이드 신청 조회/승인/거절 규칙
|
||||
- `maestro_log` 기록 규칙
|
||||
- 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
|
||||
- 기존 코드에는 없지만 신규 구현에서 보완할 사항
|
||||
|
||||
완료 기준:
|
||||
|
||||
- Phase 1 이후 구현자가 기존 PHP 코드를 다시 열지 않아도 핵심 규칙을 확인할 수 있어야 한다.
|
||||
|
||||
## 5. 조사 방법
|
||||
|
||||
권장 검색 명령:
|
||||
|
||||
```bash
|
||||
rg -n "maestro_log|extension_maestro|upgrade_maestro|PASSWORD|AvailableActivateDateTime|PlayerCount|ActivateStatus|AccountType|Status" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin
|
||||
```
|
||||
|
||||
파일별 상세 확인:
|
||||
|
||||
```bash
|
||||
sed -n '1,240p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/login.php
|
||||
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php
|
||||
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php
|
||||
```
|
||||
|
||||
HTML 호출부 확인:
|
||||
|
||||
```bash
|
||||
rg -n "ajax|fetch|XMLHttpRequest|form|server/admin|extension_maestro|upgrade_maestro|login.php" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin
|
||||
```
|
||||
|
||||
## 6. 완료 산출물
|
||||
|
||||
Phase 0 완료 시 저장소에 다음 문서가 있어야 한다.
|
||||
|
||||
- `docs/business-rules.md`
|
||||
- 기존 관리자 비즈니스 규칙의 최종 문서
|
||||
- `docs/phase/phase0.md`
|
||||
- Phase 0 수행 계획 문서
|
||||
|
||||
필요하면 다음 보조 자료를 `docs/` 아래에 추가한다.
|
||||
|
||||
- `docs/admin-file-map.md`
|
||||
- 파일 매핑이 길어질 경우 분리
|
||||
- `docs/sql-notes.md`
|
||||
- 기존 SQL 원문 기록이 길어질 경우 분리
|
||||
|
||||
## 7. 완료 체크리스트
|
||||
|
||||
- [x] `src/web/admin/` 화면 파일과 `src/web/server/admin/` 처리 파일의 연결 관계를 정리했다.
|
||||
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식을 코드 기준으로 확정했다.
|
||||
- [x] 연장 승인/취소 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
|
||||
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부를 코드 기준으로 확정했다.
|
||||
- [x] 업그레이드 승인/거절 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
|
||||
- [x] `admin.Password` 저장/검증 방식을 코드 기준으로 확정했다.
|
||||
- [x] `maestro_log.Type` 값과 `Remark` 형식을 액션별로 정리했다.
|
||||
- [x] 기존 코드에 트랜잭션/중복 처리 방지가 부족한 지점을 신규 구현 보완사항으로 표시했다.
|
||||
- [x] 조사 결과를 `docs/business-rules.md`에 문서화했다.
|
||||
- [x] `docs/plan/project-plan.md`의 Phase 0 체크리스트를 완료 처리할 수 있는 상태가 되었다.
|
||||
|
||||
## 8. 리스크와 주의사항
|
||||
|
||||
- 기존 PHP 코드가 트랜잭션 없이 여러 SQL을 순차 실행할 수 있다. 신규 서비스는 기존 결과는 동일하게 유지하되, 변경 액션은 트랜잭션으로 보완한다.
|
||||
- 기존 코드에 중복 처리 방지 조건이 없더라도 신규 API는 `Status = 1` 조건 확인 후 변경해야 한다.
|
||||
- MariaDB `PASSWORD()` 함수는 최신 인증용 해시로 적합하지 않다. Phase 0에서는 기존 검증 방식을 정확히 재현하고, 해시 업그레이드는 별도 후속 과제로 분리한다.
|
||||
- 운영 DB 스키마는 임의 변경하지 않는다.
|
||||
- 문서화 시 추측과 코드로 확인된 사실을 구분해 적는다.
|
||||
|
||||
## 9. 예상 일정
|
||||
|
||||
| 순서 | 작업 | 예상 소요 |
|
||||
|---|---|---:|
|
||||
| 1 | 파일 인벤토리와 호출 관계 정리 | 30분 |
|
||||
| 2 | 로그인/인증 로직 조사 | 20분 |
|
||||
| 3 | 목록 조회 SQL 조사 | 40분 |
|
||||
| 4 | 등록/활성화 처리 조사 | 30분 |
|
||||
| 5 | 연장 승인/취소 처리 조사 | 40분 |
|
||||
| 6 | 업그레이드 승인/거절 처리 조사 | 40분 |
|
||||
| 7 | `docs/business-rules.md` 작성 및 검토 | 60분 |
|
||||
|
||||
총 예상 소요: 약 4시간
|
||||
@@ -0,0 +1,176 @@
|
||||
# Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
|
||||
|
||||
## 목표
|
||||
|
||||
`/maestros/[maestroId]` 상세 화면에서 마에스트로 정보를 직접 수정할 수 있는 폼을 추가하고, 학생 목록을 서버 사이드 페이지네이션과 이름 검색이 가능한 형태로 완성한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 마에스트로 상세 화면 수정 폼 구현
|
||||
|
||||
### 사전 조사 결과 (기존 mouse-typing 코드 확인 완료)
|
||||
|
||||
| 조사 항목 | 파일 위치 | 결과 |
|
||||
|---|---|---|
|
||||
| 이름 중복 검사 기준 | `server/maestro/update_maestro_info.php` | **중복 검사 없음** — 이름을 그대로 UPDATE. chocoadmin에서 신규 추가. |
|
||||
| 이메일 변경 처리 | `server/maestro/update_maestro_info.php` | 변경 시 이메일 안내 메일 발송 + `maestro_log` 기록. 메일은 Phase 11에서 구현. |
|
||||
| 입장코드 수정 컬럼 | `server/maestro/set_allow_edit_entercode.php` | `maestro.AllowEditEnterCode`: `0`(비허용) / `1`(허용). 로그 기록 없음. |
|
||||
| 상태/계정유형 변경 로그 | `server/admin/` 전체 | 기존 관리자에서 상태/계정유형/사용가능일을 직접 수정하는 기능 없음. 신규 기능이므로 신규 로그 타입 정의. |
|
||||
|
||||
정의한 신규 로그 타입 (기존 코드에 없음):
|
||||
|
||||
| Type | 대상 필드 | Remark 형식 |
|
||||
|---|---|---|
|
||||
| `update_maestro_status` | `ActivateStatus` | `{prev} -> {new}` (숫자) |
|
||||
| `update_maestro_account_type` | `AccountType` | `{prev} -> {new}` (숫자) |
|
||||
| `update_maestro_available_date` | `AvailableActivateDateTime` | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
|
||||
| `update_maestro_allow_enter_code` | `AllowEditEnterCode` | `{prev} -> {new}` (0 or 1) |
|
||||
|
||||
### API 구현: `PATCH /api/maestros/[id]` ✅
|
||||
|
||||
파일: `app/api/maestros/[id]/route.ts` (기존 GET과 동일 파일에 PATCH 추가)
|
||||
서비스 레이어: `lib/maestros.ts`에 `updateMaestroSchema`, `UpdateMaestroPayload`, `updateMaestro()` 추가
|
||||
|
||||
**요청 스키마 (Zod)**
|
||||
|
||||
| 필드 | 타입 | 규칙 |
|
||||
|---|---|---|
|
||||
| `name` | `string` | 1자 이상, trim |
|
||||
| `email` | `string` | 이메일 형식 |
|
||||
| `activateStatus` | `0 \| 1 \| 2 \| 100` | `ACTIVATE_STATUSES` 상수 |
|
||||
| `accountType` | `1 \| 2 \| 3 \| 4 \| 5` | 유료 타입만 허용 (`PaidAccountType`) |
|
||||
| `availableActivateDateTime` | `string` | ISO datetime 또는 `YYYY-MM-DD` 형식 |
|
||||
| `allowEditEnterCode` | `0 \| 1` | 정수 |
|
||||
|
||||
모든 필드는 optional — 전달된 필드만 업데이트한다.
|
||||
|
||||
**처리 순서**
|
||||
|
||||
1. `withApiHandler`로 인증 자동 검사
|
||||
2. 대상 maestro 존재 확인 (`findUnique`) — 없으면 404
|
||||
3. `name`이 포함된 경우: 동일 이름의 다른 마에스트로 존재 여부 확인 — 있으면 409
|
||||
4. 변경 필드 추출 (전달값 vs 현재값 비교, 동일한 값이면 변경 대상에서 제외)
|
||||
5. 변경 필드가 없으면 200 + `{ changed: false }` 반환 (DB 업데이트 및 로그 기록 생략)
|
||||
6. 트랜잭션: `maestro` 업데이트 + `maestro_log` INSERT (변경된 필드별로 각각 로그 기록)
|
||||
7. 성공 응답: 200 + `{ changed: true }`
|
||||
|
||||
**응답 정책**
|
||||
|
||||
| 상황 | HTTP | 비고 |
|
||||
|---|---|---|
|
||||
| 변경 없음 | 200 | `{ changed: false }` |
|
||||
| 성공 | 200 | `{ changed: true }` |
|
||||
| 이름 중복 | 409 | 자기 자신은 중복으로 처리하지 않음 |
|
||||
| 존재하지 않는 마에스트로 | 404 | |
|
||||
| 유효하지 않은 값 | 400 | Zod 파싱 실패 |
|
||||
| 인증 없음 | 401 | `withApiHandler` 처리 |
|
||||
|
||||
### UI 구현: 수정 폼 ✅
|
||||
|
||||
파일: `app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx` (신규 Client Component)
|
||||
|
||||
현재 상세 페이지(`page.tsx`)의 "기본 정보" 섹션을 읽기 전용 표시에서 편집 가능한 폼으로 교체한다.
|
||||
|
||||
**필드 구성**
|
||||
|
||||
| 필드 | 컴포넌트 | 비고 |
|
||||
|---|---|---|
|
||||
| 이름 | `<Input type="text">` | |
|
||||
| 이메일 | `<Input type="email">` | |
|
||||
| 상태 | `<Select>` | `ACTIVATE_STATUS_LABELS` 기반 |
|
||||
| 계정유형 | `<Select>` | `ACCOUNT_TYPE_LABELS` 기반, 유료 타입만 |
|
||||
| 사용 가능일 | `<Input type="datetime-local">` | |
|
||||
| 입장코드 수정 | `<Select>` | 허용(1) / 비허용(0) |
|
||||
|
||||
**제출 흐름**
|
||||
|
||||
1. 저장 버튼 클릭 → 확인 다이얼로그 표시
|
||||
2. 확인 → `PATCH /api/maestros/[id]` 호출, 저장 버튼 비활성화
|
||||
3. 성공 → 성공 토스트 + `router.refresh()`로 화면 갱신
|
||||
4. 실패 → 실패 토스트 (이름 중복 409 응답 시 "이미 사용 중인 이름입니다." 별도 메시지)
|
||||
|
||||
---
|
||||
|
||||
## 2. 학생 목록 구현
|
||||
|
||||
### API 구현: `GET /api/maestros/[id]/students` ✅
|
||||
|
||||
파일: `app/api/maestros/[id]/students/route.ts` (신규)
|
||||
서비스 레이어: `lib/maestros.ts`에 `studentSearchSchema`, `MaestroStudentsResult`, `getMaestroStudents()` 추가
|
||||
|
||||
**쿼리 파라미터**
|
||||
|
||||
| 파라미터 | 타입 | 기본값 | 설명 |
|
||||
|---|---|------|---|
|
||||
| `page` | number | `1` | 페이지 번호 |
|
||||
| `pageSize` | `10 \| 20 \| 50` | `10` | 페이지 크기 |
|
||||
| `q` | string | `""` | 이름 검색 (`LIKE %q%`) |
|
||||
|
||||
**응답 형식**
|
||||
|
||||
```ts
|
||||
{
|
||||
items: Array<{
|
||||
playerID: number;
|
||||
name: string;
|
||||
enterCode: string;
|
||||
accountType: number | null;
|
||||
}>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
}
|
||||
```
|
||||
|
||||
**처리 순서**
|
||||
|
||||
1. `withApiHandler`로 인증 검사
|
||||
2. maestro 존재 확인 — 없으면 404
|
||||
3. 쿼리 파라미터 Zod 파싱
|
||||
4. `player` 테이블에서 `MaestroID` 조건 + 이름 `LIKE` 검색 + 페이지네이션 조회
|
||||
5. 정렬: `PlayerID DESC`
|
||||
|
||||
### UI 구현: 학생 목록 ✅
|
||||
|
||||
현재 "학생 목록 요약" 섹션(최근 20명 고정)을 서버 사이드 페이지네이션 목록으로 교체한다.
|
||||
|
||||
파일: `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` (신규 Client Component)
|
||||
|
||||
**구성 요소**
|
||||
|
||||
- 이름 검색 입력 (`<Input>`) + 검색 버튼
|
||||
- 전체 학생 수 표시 (`전체 N명`)
|
||||
- 학생 목록 테이블 (`PlayerID`, `이름`, `입장코드`)
|
||||
- 페이지네이션 컨트롤 — 마에스트로 목록과 동일한 슬라이딩 윈도우 형식 (현재 페이지 ±2, 처음/끝 이동, ±5 그룹 이동)
|
||||
- 빈 목록 상태: "등록된 학생이 없습니다."
|
||||
- 검색 결과 없음 상태: "검색 결과가 없습니다."
|
||||
|
||||
**동작**
|
||||
|
||||
- 검색어 입력 후 Enter 또는 검색 버튼 클릭으로 조회 (실시간 debounce 없음)
|
||||
- 검색어 변경 시 page를 1로 초기화
|
||||
- 페이지 이동은 URL query string 없이 컴포넌트 state로 관리
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 ✅
|
||||
|
||||
| 항목 | 방법 | 기대 결과 | 실행 결과 |
|
||||
|---|---|---|---|
|
||||
| 이름 중복 저장 실패 | 다른 마에스트로와 동일한 이름으로 저장 시도 | 409, "이미 사용 중인 이름입니다." | ✅ 409 + `{"message":"이미 사용 중인 이름입니다."}` |
|
||||
| 이름 자기 자신과 동일 | 현재 이름 그대로 저장 | 변경 없음 처리 또는 중복 아님으로 정상 처리 | ✅ 200 + `{"changed":false}` |
|
||||
| 정보 수정 성공 후 DB 반영 | 이름 / 이메일 / 상태 / 계정유형 / 사용 가능일 / 입장코드 수정 변경 후 DB 직접 조회 | 변경값 반영 확인 | ✅ 모든 필드 DB 반영 확인 |
|
||||
| maestro_log 기록 확인 | 정보 수정 성공 후 `maestro_log` 조회 | 변경 필드별 `Type`, `Remark` 형식이 §1 정의와 일치 | ✅ 6개 Type 모두 정확한 형식으로 기록됨 |
|
||||
| 변경 없는 저장 | 현재 값 그대로 저장 시도 | `{ changed: false }` 응답, `maestro_log` 미기록 | ✅ 200 + `{"changed":false}`, 로그 없음 |
|
||||
| 학생 목록 페이지네이션 | 학생 수가 pageSize 초과인 마에스트로에서 다음 페이지 이동 | 정확한 레코드 표시, `totalCount` 일치 | ✅ 823명 마에스트로: totalPages=83, page=2 정상 |
|
||||
| 학생 목록 이름 검색 | 존재하는 이름 일부로 검색 | 해당 학생만 필터링 | ✅ `q=최건` → totalCount=1, 해당 학생만 반환 |
|
||||
| 학생 목록 검색 결과 없음 | 존재하지 않는 이름으로 검색 | 빈 상태 메시지 표시 | ✅ totalCount=0, items=[] |
|
||||
| 비인증 접근 차단 | 세션 없이 `PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students` 호출 | 401 응답 | ✅ 401 + `{"message":"Unauthorized"}` |
|
||||
|
||||
### 부가 수정 사항
|
||||
|
||||
- `proxy.ts` matcher 수정: `api/auth` → `api/` (API 라우트 전체를 미들웨어 제외 대상으로 변경)
|
||||
- 기존: API 라우트에 미들웨어가 개입하여 307 리다이렉트 반환
|
||||
- 수정 후: API 라우트는 `withApiHandler`가 직접 401 처리
|
||||
@@ -0,0 +1,321 @@
|
||||
# Phase 11. 이메일 발송 환경 및 자동 발송
|
||||
|
||||
## 목표
|
||||
|
||||
연장 승인 / 업그레이드 승인 완료 시 마에스트로에게 안내 이메일을 자동 발송한다.
|
||||
발송은 DB 커밋 성공 후 트랜잭션 바깥에서 수행하며, 발송 실패가 승인 결과에 영향을 주지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 조사 결과 (기존 mouse-typing 코드 확인)
|
||||
|
||||
### SMTP 설정 (`mail_setting.php`, `send_jinaju_mail.php`)
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| SMTP host | `smtp.gmail.com` |
|
||||
| Port | `465` |
|
||||
| Secure | SMTPS (implicit TLS) |
|
||||
| Auth username | `support+chocomae@jinaju.com` |
|
||||
| From name | `초코마에` |
|
||||
| From address | `support+chocomae@jinaju.com` |
|
||||
| Reply-to | `support+chocomae@jinaju.com` |
|
||||
| BCC | `support+chocomae.automail@jinaju.com` |
|
||||
|
||||
Gmail App Password는 환경변수 `MAIL_SMTP_PASSWORD`로 분리. 코드에 하드코딩하지 않는다.
|
||||
|
||||
### 연장 완료 이메일 (`extensionDone`, `mail_contents.php:165`)
|
||||
|
||||
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정 유효기간 연장 완료`
|
||||
- **본문 변수**: `{maestro_name}`, `{new_available_date}` (YYYY-MM-DD 형식)
|
||||
- **본문 내용**:
|
||||
- `{maestro_name} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.`
|
||||
- `새로운 유효기간 : {new_available_date}`
|
||||
- 상위 요금제 업그레이드 안내, 6개월 후 자동 삭제 안내
|
||||
|
||||
### 업그레이드 완료 이메일 (2가지 케이스, `mail_contents.php:209,255`)
|
||||
|
||||
업그레이드 전 상태(`RegisteredActivateStatus`)에 따라 본문이 다르다.
|
||||
|
||||
**케이스 A — 체험 → 유료** (`trialMaestroUpgradeDone`, `RegisteredActivateStatus = 1`)
|
||||
|
||||
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
- **본문 핵심**: `[ {prev_student_count}명 (0원 : 무료 체험 기간) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
|
||||
- `{prev_price}` 없음 — 체험 기간이므로 0원 고정 문자열 사용
|
||||
|
||||
**케이스 B — 유료 → 유료** (`upgradeDone`, `RegisteredActivateStatus = 2`)
|
||||
|
||||
- **제목**: 케이스 A와 동일
|
||||
- **본문 핵심**: `[ {prev_student_count}명 ({prev_price}원) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
|
||||
- `{prev_price}` 있음
|
||||
|
||||
두 케이스 모두 공통 마무리: `마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.`
|
||||
|
||||
---
|
||||
|
||||
## 2. 메일 발송 정책
|
||||
|
||||
| 항목 | 정책 |
|
||||
|---|---|
|
||||
| **발송 시점** | DB 커밋 완료 후, 트랜잭션 바깥에서 발송 |
|
||||
| **발송 실패 처리** | `logger.error`로 기록, 승인 응답은 200으로 그대로 반환 (롤백 없음) |
|
||||
| **중복 발송 방지** | 승인 API는 이미 처리된 건(`Status ≠ 1`)에 409를 반환하므로 발송 자체가 중복 호출되지 않음 |
|
||||
| **발송 비활성화** | `MAIL_SEND_ENABLED=false`이면 발송을 건너뛰고 info 로그만 남김 |
|
||||
| **민감정보 마스킹** | SMTP 비밀번호는 환경변수로 관리, 로그에는 수신자 주소와 제목만 출력 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 순서
|
||||
|
||||
### 3-1. 패키지 설치
|
||||
|
||||
```bash
|
||||
pnpm add nodemailer
|
||||
pnpm add -D @types/nodemailer
|
||||
```
|
||||
|
||||
nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다.
|
||||
|
||||
### 3-2. 환경변수 추가 (`.env.local`)
|
||||
|
||||
```dotenv
|
||||
MAIL_SMTP_HOST=smtp.gmail.com
|
||||
MAIL_SMTP_PORT=465
|
||||
MAIL_SMTP_SECURE=true
|
||||
MAIL_SMTP_USER=support+chocomae@jinaju.com
|
||||
MAIL_SMTP_PASSWORD=<gmail_app_password>
|
||||
MAIL_FROM_NAME=초코마에
|
||||
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
|
||||
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
|
||||
MAIL_SEND_ENABLED=false # local/stage 발송 테스트 전까지 false 유지
|
||||
```
|
||||
|
||||
### 3-3. `lib/mail.ts` 구현 (신규)
|
||||
|
||||
```
|
||||
lib/mail.ts
|
||||
```
|
||||
|
||||
**내부 구조**
|
||||
|
||||
```
|
||||
createTransporter() — nodemailer transport 생성 (환경변수 기반)
|
||||
sendMail(options) — 공통 발송 함수 (MAIL_SEND_ENABLED 체크, 로그, BCC)
|
||||
sendExtensionDoneEmail(...) — 연장 완료 이메일
|
||||
sendUpgradeDoneEmail(...) — 업그레이드 완료 이메일 (체험/유료 케이스 내부 분기)
|
||||
```
|
||||
|
||||
**`sendExtensionDoneEmail` 시그니처**
|
||||
|
||||
```typescript
|
||||
sendExtensionDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
newAvailableDate: string // YYYY-MM-DD
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
**`sendUpgradeDoneEmail` 시그니처**
|
||||
|
||||
```typescript
|
||||
sendUpgradeDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2)
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number,
|
||||
newAvailableDate: string // YYYY-MM-DD
|
||||
): Promise<boolean>
|
||||
```
|
||||
|
||||
내부에서 `registeredActivateStatus === ACTIVATE_STATUSES.TRIAL`이면 체험 케이스 본문을 사용한다.
|
||||
계정별 학생 수와 가격은 `getAccountTypePlayerCount()`, `getAccountTypePrice()` (`lib/utils.ts`)를 호출한다.
|
||||
|
||||
### 3-4. `lib/extension-requests.ts` 수정
|
||||
|
||||
`approveExtensionRequest`의 반환 타입에 이메일 발송에 필요한 정보를 추가한다.
|
||||
|
||||
`getActionTarget`이 이미 `include: { maestro: true }`를 사용하므로 추가 쿼리 없이 `request.maestro.Email`을 사용할 수 있다.
|
||||
|
||||
**변경 전**
|
||||
```typescript
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{ availableActivateDateTime: string }>
|
||||
```
|
||||
|
||||
**변경 후**
|
||||
```typescript
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
}>
|
||||
```
|
||||
|
||||
트랜잭션 내부 마지막에 반환값에 `maestroName`, `maestroEmail` 추가:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
};
|
||||
```
|
||||
|
||||
### 3-5. `lib/upgrade-requests.ts` 수정
|
||||
|
||||
현재 `getActionTarget`이 `maestro_upgrade`만 조회하고 `maestro`를 포함하지 않는다. include를 추가해야 한다.
|
||||
|
||||
**`getActionTarget` 변경**
|
||||
|
||||
```typescript
|
||||
const request = await tx.maestro_upgrade.findUnique({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
include: { maestro: { select: { Name: true, Email: true } } }, // 추가
|
||||
});
|
||||
```
|
||||
|
||||
**`approveUpgradeRequest` 반환 타입 변경**
|
||||
|
||||
```typescript
|
||||
export async function approveUpgradeRequest(
|
||||
maestroUpgradeID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
}>
|
||||
```
|
||||
|
||||
트랜잭션 반환값에 추가:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
maestroEmail: request.maestro.Email.trim(),
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
};
|
||||
```
|
||||
|
||||
### 3-6. API route 수정
|
||||
|
||||
**`app/api/extension-requests/[id]/route.ts`**
|
||||
|
||||
```typescript
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveExtensionRequest(maestroExtensionID);
|
||||
logger.info(CTX, "approved", { id: maestroExtensionID, duration: Date.now() - t0 });
|
||||
|
||||
// 메일 발송 (실패해도 응답에 영향 없음)
|
||||
void sendExtensionDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
formatDate(result.availableActivateDateTime),
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
```
|
||||
|
||||
**`app/api/upgrade-requests/[id]/route.ts`**
|
||||
|
||||
```typescript
|
||||
if (body.data.action === "approve") {
|
||||
const result = await approveUpgradeRequest(maestroUpgradeID);
|
||||
logger.info(CTX, "approved", { id: maestroUpgradeID, duration: Date.now() - t0 });
|
||||
|
||||
void sendUpgradeDoneEmail(
|
||||
result.maestroName,
|
||||
result.maestroEmail,
|
||||
result.registeredActivateStatus,
|
||||
result.registeredAccountType,
|
||||
result.requestedAccountType,
|
||||
formatDate(result.availableActivateDateTime),
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
|
||||
}
|
||||
```
|
||||
|
||||
`void`를 붙여 메일 발송을 백그라운드로 실행하되, `sendMail` 내부에서 `await`로 실제 발송을 기다린다. 메일 발송 실패는 `logger.error`로만 처리한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 이메일 본문 (참조용)
|
||||
|
||||
### 연장 완료
|
||||
|
||||
**제목**: `[초코마에] {maestroName} - 마에스트로 계정 유효기간 연장 완료`
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
|
||||
* 새로운 유효기간 : {newAvailableDate}<br/>
|
||||
<br/>
|
||||
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
|
||||
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
### 업그레이드 완료 — 체험 → 유료
|
||||
|
||||
**제목**: `[초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
### 업그레이드 완료 — 유료 → 유료
|
||||
|
||||
**제목**: 위와 동일
|
||||
|
||||
```html
|
||||
안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증 절차
|
||||
|
||||
| 항목 | 방법 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| `MAIL_SEND_ENABLED=false` 상태에서 승인 | 연장/업그레이드 승인 API 호출 | 승인 성공, 메일 발송 없음, `info: send skipped` 로그 출력 |
|
||||
| `MAIL_SEND_ENABLED=true` — 연장 승인 이메일 | 연장 승인 후 수신 메일함 확인 | 제목/본문/수신자/BCC 형식 일치 |
|
||||
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (체험) | 체험 마에스트로 업그레이드 승인 | `0원 : 무료 체험 기간` 문구 포함, 제목/본문 일치 |
|
||||
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (유료) | 유료 마에스트로 업그레이드 승인 | `{prev_price}원` 포함, 제목/본문 일치 |
|
||||
| SMTP 오류 시 응답 | SMTP 연결 실패 환경에서 승인 | 승인 API 200 응답, `error: send failed` 로그 |
|
||||
| 로그 민감정보 확인 | 발송 로그 출력 | 수신자 주소, 제목만 출력. SMTP 비밀번호 미포함 |
|
||||
@@ -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가 모바일에서 오른쪽 정렬 확인
|
||||
- [ ] 데스크탑에서 기존 레이아웃 회귀 없음 확인
|
||||
@@ -0,0 +1,139 @@
|
||||
# Phase 8. 검증 및 안정화
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`docs/plan/project-plan.md`의 `Phase 8. 검증 및 안정화`를 수행하기 위한 검증 항목과 실행 절차를 정리한다.
|
||||
|
||||
Phase 8의 목표는 다음 항목을 확인하는 것이다.
|
||||
|
||||
- 승인/취소/거절 API가 트랜잭션으로 처리되는지 확인
|
||||
- 이미 처리된 신청을 재처리할 때 `409` 오류로 막히는지 확인
|
||||
- 비인증 접근이 차단되는지 확인
|
||||
- Synology NAS Docker 재시작 후 정상 동작하는지 확인
|
||||
|
||||
## 2. 코드 검증 결과
|
||||
|
||||
### 2-1. 연장 신청 처리
|
||||
|
||||
대상 파일:
|
||||
|
||||
- `lib/extension-requests.ts`
|
||||
- `app/api/extension-requests/[id]/route.ts`
|
||||
|
||||
확인 내용:
|
||||
|
||||
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
|
||||
- 취소 처리는 `db.$transaction()` 안에서 실행된다.
|
||||
- 처리 대상 조회 시 `maestro_extension.Status`가 `REQUEST_STATUSES.REQUESTED`인지 확인한다.
|
||||
- 이미 처리된 건은 `ExtensionRequestActionError`를 던지고 API route에서 `409`로 응답한다.
|
||||
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
|
||||
|
||||
### 2-2. 업그레이드 신청 처리
|
||||
|
||||
대상 파일:
|
||||
|
||||
- `lib/upgrade-requests.ts`
|
||||
- `app/api/upgrade-requests/[id]/route.ts`
|
||||
|
||||
확인 내용:
|
||||
|
||||
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
|
||||
- 거절 처리는 `db.$transaction()` 안에서 실행된다.
|
||||
- 처리 대상 조회 시 `maestro_upgrade.Status`가 `REQUEST_STATUSES.REQUESTED`인지 확인한다.
|
||||
- 이미 처리된 건은 `UpgradeRequestActionError`를 던지고 API route에서 `409`로 응답한다.
|
||||
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
|
||||
|
||||
### 2-3. 인증 프록시
|
||||
|
||||
대상 파일:
|
||||
|
||||
- `proxy.ts`
|
||||
|
||||
확인 내용:
|
||||
|
||||
- `/login`만 공개 경로로 둔다.
|
||||
- 비로그인 사용자가 관리자 경로에 접근하면 `/login?callbackUrl=...`로 이동한다.
|
||||
- HTTPS 배포 환경에서 Auth.js secure session cookie를 읽도록 `secureCookie`를 설정한다.
|
||||
|
||||
## 3. 실행 검증 절차
|
||||
|
||||
### 3-1. 로컬 기본 검증
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm build
|
||||
pnpm db:check
|
||||
```
|
||||
|
||||
### 3-2. 비인증 접근 차단 확인
|
||||
|
||||
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
|
||||
|
||||
```bash
|
||||
curl -I https://chocoadmin-stage.jisangs.com/maestros
|
||||
```
|
||||
|
||||
기대 결과:
|
||||
|
||||
- `/login?callbackUrl=%2Fmaestros`로 리다이렉트된다.
|
||||
|
||||
### 3-3. 로그인 후 화면 확인
|
||||
|
||||
브라우저에서 확인한다.
|
||||
|
||||
- `https://chocoadmin-stage.jisangs.com/login`
|
||||
- `/maestros`
|
||||
- `/extension-requests`
|
||||
- `/upgrade-requests`
|
||||
|
||||
로그인 후 `ERR_TOO_MANY_REDIRECTS`가 발생하면 다음을 확인한다.
|
||||
|
||||
- `.env.stage`의 `AUTH_URL`
|
||||
- `.env.stage`의 `NEXTAUTH_URL`
|
||||
- Nginx Proxy Manager의 forward target
|
||||
- 브라우저 쿠키 삭제 여부
|
||||
|
||||
### 3-4. 중복 처리 방지 확인
|
||||
|
||||
스테이지 DB에서 테스트 가능한 요청 건을 대상으로 진행한다.
|
||||
|
||||
1. `/extension-requests` 또는 `/upgrade-requests`에서 `요청` 상태 건을 선택한다.
|
||||
2. 승인/취소/거절 중 하나를 실행한다.
|
||||
3. 같은 요청에 같은 액션을 다시 실행한다.
|
||||
4. 두 번째 요청이 실패하는지 확인한다.
|
||||
|
||||
기대 결과:
|
||||
|
||||
- 첫 번째 요청은 성공한다.
|
||||
- 두 번째 요청은 `409` 상태와 `이미 처리된 ... 신청입니다.` 메시지로 실패한다.
|
||||
- 목록을 새로고침하면 상태가 변경되어 있다.
|
||||
- 관련 `maestro_log`가 1회만 생성된다.
|
||||
|
||||
주의:
|
||||
|
||||
- 이 검증은 DB 데이터를 실제로 변경한다.
|
||||
- 운영 DB에서는 read-only 리허설 또는 별도 테스트 데이터를 사용한 뒤 진행한다.
|
||||
|
||||
### 3-5. Docker 재시작 확인
|
||||
|
||||
NAS에서 실행한다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/service/jisangs/chocoadmin-stage
|
||||
docker compose -f docker-compose.stage.yml restart
|
||||
docker compose -f docker-compose.stage.yml ps
|
||||
docker compose -f docker-compose.stage.yml logs --tail=100
|
||||
```
|
||||
|
||||
기대 결과:
|
||||
|
||||
- `chocoadmin-stage` 컨테이너가 `Up` 상태다.
|
||||
- 로그에 Next.js ready 메시지가 출력된다.
|
||||
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
|
||||
|
||||
## 4. 안정화 메모
|
||||
|
||||
- 스테이지는 `proxy-network`를 통해 Nginx Proxy Manager와 연결한다.
|
||||
- 스테이지 컨테이너는 host port `3000`을 직접 bind하지 않고 container port `3000`만 노출한다.
|
||||
- `AUTH_URL`과 `NEXTAUTH_URL`은 실제 외부 접속 URL과 scheme까지 정확히 일치해야 한다.
|
||||
- HTTPS 배포 환경의 로그인 루프는 secure cookie 이름 불일치가 원인일 수 있다.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Phase 9. 실행 로그 및 DB 쿼리 로그
|
||||
|
||||
## 로그 정책
|
||||
|
||||
### 환경 구분
|
||||
|
||||
| `APP_ENV` | 설명 |
|
||||
|---|---|
|
||||
| `local` | 맥미니 개발 환경 |
|
||||
| `stage` | NAS에서 stage DB로 구동하는 검증 환경 |
|
||||
| `production` | NAS에서 운영 DB로 구동하는 실제 서비스 환경 |
|
||||
|
||||
`NODE_ENV`는 Next.js 빌드 방식(dev/prod)을 제어하고, `APP_ENV`는 로그 레벨·상세도를 제어한다.
|
||||
|
||||
### 로그 레벨
|
||||
|
||||
| `LOG_LEVEL` | 출력 대상 |
|
||||
|---|---|
|
||||
| `debug` | debug + info + warn + error |
|
||||
| `info` | info + warn + error |
|
||||
| `warn` | warn + error |
|
||||
| `error` | error만 |
|
||||
|
||||
`LOG_LEVEL`을 명시하지 않으면 `APP_ENV=production`일 때 `info`, 그 외는 `debug`가 기본값.
|
||||
|
||||
### 출력 형식
|
||||
|
||||
```
|
||||
[2026-06-22T10:00:00.000Z] [INFO ] [maestros] list fetched {"totalCount":120,"duration":45}
|
||||
[2026-06-22T10:00:00.000Z] [WARN ] [extension-requests/[id]] 이미 처리된 연장 신청입니다. {"id":5,"status":409,"duration":12}
|
||||
[2026-06-22T10:00:00.000Z] [ERROR] [upgrade-requests/[id]] unexpected error {"id":3,"error":"...","duration":5}
|
||||
```
|
||||
|
||||
- `warn` / `error`는 stderr, 그 외는 stdout으로 출력한다.
|
||||
- JSON 데이터는 한 줄로 직렬화한다 (Docker 로그 파싱 편의).
|
||||
|
||||
### 민감정보 마스킹 대상
|
||||
|
||||
아래 값은 로그에 포함하지 않는다:
|
||||
|
||||
- 관리자 패스워드 (form 입력값, DB 조회 파라미터)
|
||||
- `AUTH_SECRET` / `NEXTAUTH_SECRET`
|
||||
- `DATABASE_URL` (패스워드 포함)
|
||||
- 세션 토큰 / JWT 페이로드 원문
|
||||
- SMTP 패스워드
|
||||
|
||||
현재 `auth.ts`의 raw SQL 파라미터에 패스워드가 포함될 수 있으므로 DB query 이벤트 핸들러 구현 시 `PASSWORD(` 포함 쿼리의 params를 마스킹한다 (Phase 9 DB query 로그 구현 시 처리).
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
| 변수 | 설명 | 기본값 |
|
||||
|---|---|---|
|
||||
| `APP_ENV` | 환경 구분 (`local`\|`stage`\|`production`) | `local` |
|
||||
| `LOG_LEVEL` | 최소 로그 레벨 (`debug`\|`info`\|`warn`\|`error`) | `APP_ENV`에 따라 자동 |
|
||||
| `DB_SLOW_QUERY_MS` | DB 쿼리 slow query 임계값(ms) — DB query 로그 구현 시 사용 | `1000` |
|
||||
|
||||
### 권장 `.env` 설정
|
||||
|
||||
**.env.local (개발)**
|
||||
```env
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
**.env.stage (검증)**
|
||||
```env
|
||||
APP_ENV=stage
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
**.env.production (운영)**
|
||||
```env
|
||||
APP_ENV=production
|
||||
LOG_LEVEL=info
|
||||
DB_SLOW_QUERY_MS=500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 현황
|
||||
|
||||
### 완료
|
||||
|
||||
- `lib/logger.ts` — 공통 logger 모듈 (레벨 필터링, 포맷, stderr/stdout 분기)
|
||||
- 로그인 성공/실패 로그 (`auth.ts`)
|
||||
- 마에스트로 목록/상세 조회 로그
|
||||
- 연장 신청 조회/승인/취소 로그 (409는 warn, 그 외 예외는 error)
|
||||
- 업그레이드 신청 조회/승인/거절 로그 (409는 warn, 그 외 예외는 error)
|
||||
|
||||
### 미완료 (별도 확인 후 진행)
|
||||
|
||||
- **Correlation ID**: Edge Middleware(`proxy.ts`)에서 `x-correlation-id` 헤더 생성 → API route에서 AsyncLocalStorage로 전달하는 구조. 구현 전 설계 확정 필요.
|
||||
- **DB query 로그**: `@prisma/adapter-mariadb` + Prisma query 이벤트 호환성 확인 필요. `$on('query', handler)` 발화 여부를 local에서 테스트한 뒤 진행.
|
||||
- **결과 row/count 로그**: Prisma query 이벤트에 결과 데이터가 없음. `$extends` query 컴포넌트 방식으로 재설계하거나 계획 범위 축소 필요.
|
||||
|
||||
---
|
||||
|
||||
## Docker / NAS 로그 운영 절차
|
||||
|
||||
### 애플리케이션 로그 확인
|
||||
|
||||
```bash
|
||||
# 실시간 스트리밍
|
||||
docker compose logs -f chocoadmin
|
||||
|
||||
# 최근 100줄
|
||||
docker compose logs --tail=100 chocoadmin
|
||||
|
||||
# 특정 시간 이후
|
||||
docker compose logs --since="2026-06-22T10:00:00" chocoadmin
|
||||
```
|
||||
|
||||
### warn/error만 필터링
|
||||
|
||||
```bash
|
||||
docker compose logs -f chocoadmin 2>&1 | grep -E '\[(WARN |ERROR)\]'
|
||||
```
|
||||
|
||||
### Nginx Proxy Manager 로그와 함께 확인
|
||||
|
||||
Synology NAS에서 Nginx Proxy Manager(NPM)와 chocoadmin 로그를 같이 보려면:
|
||||
|
||||
```bash
|
||||
# NPM access log (컨테이너 이름은 환경에 맞게 조정)
|
||||
docker logs nginx-proxy-manager 2>&1 | tail -50
|
||||
|
||||
# 두 로그를 동시에
|
||||
docker compose logs -f chocoadmin &
|
||||
docker logs -f nginx-proxy-manager
|
||||
```
|
||||
|
||||
NPM의 access log에는 클라이언트 IP, HTTP method, path, status code, 응답 시간이 기록되고, chocoadmin 로그에는 인증 결과와 비즈니스 처리 결과가 기록된다. `status=409`를 NPM 로그에서 발견했을 때 chocoadmin의 `[WARN]` 로그와 함께 원인을 추적한다.
|
||||
|
||||
### stage/production 로그 레벨 차이
|
||||
|
||||
| 항목 | stage | production |
|
||||
|---|---|---|
|
||||
| `LOG_LEVEL` | `debug` | `info` |
|
||||
| 요청 시작 로그 | 출력 (debug) | 미출력 |
|
||||
| 성공 응답 로그 | 출력 (info) | 출력 (info) |
|
||||
| 인증 실패/409 로그 | 출력 (warn, stderr) | 출력 (warn, stderr) |
|
||||
| 예외/500 로그 | 출력 (error, stderr) | 출력 (error, stderr) |
|
||||
@@ -0,0 +1,59 @@
|
||||
# Chocomae Email Sending Report
|
||||
|
||||
조사 기준:
|
||||
|
||||
- 현재 `chocoadmin` 저장소에는 이메일 발송 구현이 없다.
|
||||
- 기존 서비스 원본은 `/Users/jisangs/work/jinaju/git/web/mouse-typing` 기준으로 확인했다.
|
||||
- 핵심 구현 파일:
|
||||
- `src/web/server/mail/send_jinaju_mail.php`
|
||||
- `src/web/server/mail/mail_contents.php`
|
||||
- `src/web/server/mail/mail_setting.php`
|
||||
|
||||
## 공통 발송 설정
|
||||
|
||||
- 발송자 이름: `초코마에`
|
||||
- 발송자 주소: `support+chocomae@jinaju.com`
|
||||
- BCC: `support+chocomae.automail@jinaju.com`
|
||||
- 기존 구현은 PHPMailer + SMTP를 사용한다.
|
||||
- 일부 배치 스크립트는 NCloud Mail API 템플릿 ID를 사용한다.
|
||||
- 기존 코드에는 SMTP/API 키가 하드코딩되어 있으므로, 신규 구현 시 반드시 환경변수 또는 배포 시크릿으로 분리해야 한다.
|
||||
|
||||
## 운영 플로우에서 호출되는 메일
|
||||
|
||||
| # | 메일 | 호출 위치 | 제목 | 주요 내용 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 마에스트로 체험 등록 완료 | `src/web/server/maestro/add_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 신청 아이디, 요금제, 2주 무료 체험 종료일, 계정 정보 변경 안내 |
|
||||
| 2 | 관리자 등록 승인 완료 | `src/web/server/admin/register_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 계정 활성화 완료, 1년 사용, 업그레이드/만료/정보 변경 안내 |
|
||||
| 3 | 연장 신청 입금 안내 | `src/web/server/maestro/request_extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장을 위한 입금 안내` | 현재 요금제, 기존 유효기간, 새 유효기간, 입금 계좌, 금액, 송금자명 안내 |
|
||||
| 4 | 연장 완료 | `src/web/server/admin/extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장 완료` | 유효기간 +1년 연장 완료, 새 유효기간 안내 |
|
||||
| 5 | 체험 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 무료 체험 요금제, 신청 업그레이드 요금제, 전체 결제 금액, 입금 계좌 안내 |
|
||||
| 6 | 일반 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 요금제, 업그레이드 요금제, 차액 결제 금액, 입금 계좌 안내 |
|
||||
| 7 | 체험 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 무료 체험 요금제에서 유료 요금제로 업그레이드 완료, 유효기간 오늘부터 1년 |
|
||||
| 8 | 일반 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년 |
|
||||
| 9 | 마에스트로 아이디 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] {{update_maestro_name}} - 마에스트로 계정 아이디 정보 변경 알림` | 변경 전/후 아이디, 다음 로그인 시 변경된 아이디 사용 안내 |
|
||||
| 10 | 마에스트로 이메일 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] 마에스트로 계정 이메일 정보 변경 알림` | 변경 전/후 이메일, 이후 알림은 변경 후 이메일로 발송 안내 |
|
||||
|
||||
## 배치/만료 관련 메일
|
||||
|
||||
| # | 메일 | 호출 위치 | 제목 또는 템플릿 | 주요 내용 |
|
||||
|---|---|---|---|---|
|
||||
| 11 | 유효기간 7일 전 만료 안내 | `src/php-cli/batch/send_mail_to_7days_left_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 - 7일 후 만료 안내` | 유효기간, 7일 후 휴면 전환, 연장/업그레이드 방법 안내 |
|
||||
| 12 | 유효기간 종료 안내 | `src/php-cli/batch/send_mail_to_overdated_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 종료 안내` | 휴면 전환, 학생 로그인 불가, 6개월 후 삭제 가능성, 재활성화 방법 안내 |
|
||||
| 13 | 미입금 계정 삭제 예고 | `src/php-cli/batch/old/send_mail_to_unpaid_maestro.php`, Python wrapper `send_mail_to_unpaid_maestro.py` | NCloud templateSid `337`; PHPMailer 템플릿 제목은 `[초코마에] {{maestro_name}} - 미입금 계정 삭제 예고 안내` | 신청 후 일주일간 미입금, 오늘 중 입금 없으면 계정 등록 정보 삭제 예정 |
|
||||
| 14 | 유효기간 30일 전 안내 | `src/php-cli/batch/old/send_mail_to_30days_left_maestro.php`, Python wrapper `send_mail_to_expire_maestro.py` | NCloud templateSid `333` | 코드에는 파라미터만 확인됨: `maestro_name`, `available_date`. 실제 제목/본문은 NCloud 콘솔 템플릿에 저장된 것으로 보인다. |
|
||||
|
||||
## 템플릿/테스트 흔적만 확인된 메일
|
||||
|
||||
| 메일 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 등록 입금 안내 | `sendMailRegisterBankInfo`, `mail_contents.php`의 `registrationBankInfo` | 제목/본문은 있으나 주요 등록 플로우에서는 호출되지 않고 관리자 테스트 화면에서 호출된다. |
|
||||
| 클로즈베타 등록 입금 안내 | `mail_contents.php`의 `closedBetaRegistrationBankInfo` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
|
||||
| 클로즈베타 등록 완료 | `mail_contents.php`의 `closedBetaRegistrationDone` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
|
||||
| 클로즈베타 유료 결제 감사/기간 +1개월 | `sendMailToClosedBetaPaidMestroEmail`, `src/php-cli/batch/old/send_mail_to_closed_beta_paid_maestro.php` | old 배치에서 호출된다. |
|
||||
|
||||
## 구현 시 참고
|
||||
|
||||
- 연장/업그레이드 승인 메일은 기존 PHP 코드에서 DB 변경 후 발송한다.
|
||||
- 신규 구현에서는 DB 트랜잭션 커밋 이후 메일 발송을 분리하는 것이 안전하다.
|
||||
- 메일 발송 실패가 신청 승인/상태 변경을 롤백해야 하는지는 별도 정책 결정이 필요하다.
|
||||
- 계좌 정보, 발송자/BCC, SMTP/API 인증 정보는 코드에 하드코딩하지 말고 환경변수로 관리해야 한다.
|
||||
@@ -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:00–08: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 위험은 낮음.
|
||||
@@ -0,0 +1,339 @@
|
||||
# chocoadmin 프로젝트 계획서
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 서비스명 | chocoadmin |
|
||||
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
|
||||
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
|
||||
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
|
||||
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|---|---|
|
||||
| 런타임 | Node.js (LTS 버전) |
|
||||
| 프레임워크 | Next.js 15 (App Router) |
|
||||
| 언어 | TypeScript |
|
||||
| DB 클라이언트 | `mysql2` (MariaDB 호환) |
|
||||
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
|
||||
| 환경변수 관리 | `.env.local` / `.env.production` |
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS Framework 추천
|
||||
|
||||
관리자 화면은 주로 표(Table)와 폼(Form) 중심이므로, 빠른 개발과 유지보수성을 모두 고려한 3가지를 추천합니다.
|
||||
|
||||
### 3-1. Tailwind CSS + shadcn/ui ⭐ 추천
|
||||
|
||||
- **Tailwind CSS**: 유틸리티 클래스 기반으로 CSS 파일을 직접 작성할 일이 거의 없음
|
||||
- **shadcn/ui**: Tailwind 기반의 복사-붙여넣기형 컴포넌트 라이브러리. Button, Table, Dialog, Form 등 관리자 UI에 필요한 컴포넌트가 이미 갖춰져 있음
|
||||
- **장점**: Next.js App Router와 궁합이 뛰어남. 번들 사이즈가 작고, 컴포넌트 코드를 직접 소유하므로 커스터마이징이 자유로움
|
||||
- **단점**: 초기 세팅이 다소 필요함
|
||||
|
||||
### 3-2. Ant Design (antd)
|
||||
|
||||
- React 생태계에서 오랫동안 검증된 전통적인 관리자 UI 라이브러리
|
||||
- Table, Form, Modal, DatePicker 등 관리자 페이지에 필요한 컴포넌트가 모두 내장되어 있음
|
||||
- **장점**: 별도 CSS 작업 없이 완성도 높은 UI를 빠르게 구성 가능
|
||||
- **단점**: 번들 사이즈가 크고, 기본 디자인 톤에서 벗어나는 커스터마이징이 어려움
|
||||
|
||||
### 3-3. Mantine
|
||||
|
||||
- shadcn/ui보다 더 완결된 형태의 컴포넌트 세트 제공 (100개 이상)
|
||||
- 자체 훅(hook) 라이브러리도 포함되어 있어 복잡한 상태 관리 없이 폼·테이블 구현 가능
|
||||
- **장점**: 문서가 잘 정리되어 있고 TypeScript 지원이 뛰어남
|
||||
- **단점**: antd처럼 디자인 자유도가 shadcn/ui보다 낮음
|
||||
|
||||
> **최종 추천: Tailwind CSS + shadcn/ui**
|
||||
> Next.js 공식 생태계와 가장 잘 맞고, 장기적인 유지보수 측면에서 가장 유리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Table 라이브러리 추천
|
||||
|
||||
관리자 화면은 목록 조회, 검색, 정렬, 페이지네이션, 인라인 액션 버튼이 핵심이므로 아래 3가지를 추천합니다.
|
||||
|
||||
### 4-1. TanStack Table (React Table v8) ⭐ 추천
|
||||
|
||||
- Headless 방식(스타일 미포함)으로, Tailwind/shadcn과 조합해 완전한 커스터마이징 가능
|
||||
- 정렬, 필터, 페이지네이션, 행 선택, 컬럼 고정 등 필요한 기능을 필요한 만큼만 추가
|
||||
- **shadcn/ui DataTable**: TanStack Table + shadcn 조합의 공식 예제가 제공되어 빠른 시작 가능
|
||||
- **장점**: 번들 사이즈 최소, 자유도 최대, Next.js App Router 완벽 호환
|
||||
- **단점**: 기능을 직접 조립해야 하므로 초기 설정 비용이 있음
|
||||
|
||||
### 4-2. AG Grid (Community Edition)
|
||||
|
||||
- 대용량 데이터에 특화된 고성능 그리드 라이브러리
|
||||
- 정렬, 필터, 그룹핑, 엑셀 내보내기, 인라인 편집 등 풍부한 기능이 내장
|
||||
- 무료 Community Edition으로도 관리자 기능 구현에 충분
|
||||
- **장점**: 기능이 가장 풍부하고 성능이 뛰어남
|
||||
- **단점**: 번들 사이즈가 크고, 스타일 커스터마이징에 별도 CSS 작업이 필요
|
||||
|
||||
### 4-3. MUI DataGrid (Free tier)
|
||||
|
||||
- Material UI 기반의 데이터 그리드
|
||||
- 정렬, 필터, 페이지네이션, 컬럼 재배치가 내장
|
||||
- **장점**: Material UI 생태계와 자연스럽게 통합, 사용법이 직관적
|
||||
- **단점**: Tailwind와 함께 사용 시 스타일 충돌 이슈가 발생할 수 있음. 고급 기능은 Pro 유료 플랜 필요
|
||||
|
||||
> **최종 추천: TanStack Table + shadcn/ui DataTable**
|
||||
> shadcn/ui와 세트로 사용하면 코드 통일성이 높고, 필요한 기능만 붙일 수 있어 관리 부담이 적습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 개발 환경 및 배포 환경
|
||||
|
||||
### 5-1. 개발 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS (DS920+, 20GB RAM) - Docker |
|
||||
| DB (개발) | localhost MariaDB |
|
||||
| 실행 방법 | `docker compose up` |
|
||||
| 포트 | 3000 |
|
||||
|
||||
### 5-2. 서비스 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS (DS920+) - Docker |
|
||||
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
|
||||
| 접근 방식 | NAS에서 AWS EC2 DB에 직접 연결 (보안그룹 인바운드 규칙에 NAS 외부 IP 허용 필요) |
|
||||
|
||||
### 5-3. docker-compose.yml 구성 계획
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (개발용)
|
||||
services:
|
||||
chocoadmin:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DB_HOST=host.docker.internal
|
||||
- DB_PORT=3306
|
||||
- DB_NAME=chocomae
|
||||
- DB_USER=...
|
||||
- DB_PASS=...
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
```
|
||||
|
||||
### 5-4. Dockerfile 계획
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터베이스 주요 테이블 정리
|
||||
|
||||
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
|
||||
|
||||
| 테이블 | 설명 | 주요 컬럼 |
|
||||
|---|---|---|
|
||||
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime` |
|
||||
| `maestro_extension` | 마에스트로 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_upgrade` | 마에스트로 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_log` | 마에스트로 처리 이력 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
|
||||
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
|
||||
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
|
||||
|
||||
### AccountType 코드표
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 20명 (1만원) |
|
||||
| 2 | 50명 (2만원) |
|
||||
| 3 | 100명 (3만원) |
|
||||
| 4 | 500명 (4만원) |
|
||||
| 5 | 1,000명 (5만원) |
|
||||
|
||||
### ActivateStatus 코드표 (maestro)
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 0 | 미승인 |
|
||||
| 1 | 체험 |
|
||||
| 2 | 활성화 |
|
||||
| 100 | 등록취소 |
|
||||
|
||||
### Status 코드표 (maestro_extension / maestro_upgrade)
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 요청 |
|
||||
| 2 | 취소 |
|
||||
| 3 | 연장/업그레이드 적용 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 주요 화면 목록 및 API 설계
|
||||
|
||||
### 7-1. 화면 구조 (Next.js App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── (auth)/
|
||||
│ └── login/ # 관리자 로그인
|
||||
├── (admin)/
|
||||
│ ├── layout.tsx # 공통 사이드바 + 헤더
|
||||
│ ├── maestro/
|
||||
│ │ ├── page.tsx # 마에스트로 전체 목록
|
||||
│ │ └── [id]/
|
||||
│ │ └── page.tsx # 마에스트로 상세 정보
|
||||
│ ├── extension/
|
||||
│ │ └── page.tsx # 연장 신청 목록 + 승인/취소
|
||||
│ └── upgrade/
|
||||
│ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
|
||||
└── api/
|
||||
├── auth/[...nextauth]/
|
||||
├── maestro/
|
||||
│ ├── route.ts # GET: 목록 조회
|
||||
│ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
|
||||
├── extension/
|
||||
│ ├── route.ts # GET: 목록 조회
|
||||
│ └── [id]/route.ts # PATCH: 승인/취소
|
||||
└── upgrade/
|
||||
├── route.ts # GET: 목록 조회
|
||||
└── [id]/route.ts # PATCH: 승인/거절
|
||||
```
|
||||
|
||||
### 7-2. 화면별 기능 명세
|
||||
|
||||
#### [1] 마에스트로 전체 목록 (`/maestro`)
|
||||
|
||||
- 전체 마에스트로 목록 표시
|
||||
- 검색: 이름, 이메일
|
||||
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
|
||||
- 정렬: 가입일, 만료일
|
||||
- 컬럼: ID / 이름 / 이메일 / 계정유형 / 상태 / 인원수 / 사용가능일 / 상세보기
|
||||
|
||||
#### [2] 마에스트로 상세 정보 (`/maestro/[id]`)
|
||||
|
||||
- 기본 정보 (이름, 이메일, 계정 유형, 상태, 만료일)
|
||||
- 플레이어(학생) 목록
|
||||
- 연장 신청 이력
|
||||
- 업그레이드 신청 이력
|
||||
- 관리자 처리 로그
|
||||
|
||||
#### [3] 연장 신청 목록 (`/extension`)
|
||||
|
||||
- 연장 신청 목록 표시 (기본: Status=1 요청 건만)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인버튼 / 취소버튼
|
||||
- 승인 시: `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime` 1년 연장, `maestro_extension.Status = 3`
|
||||
- 취소 시: `maestro_extension.Status = 2`
|
||||
|
||||
#### [4] 업그레이드 신청 목록 (`/upgrade`)
|
||||
|
||||
- 업그레이드 신청 목록 표시 (기본: Status=1 요청 건만)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인버튼 / 거절버튼
|
||||
- 승인 시: `maestro.AccountType` 변경, `maestro_upgrade.Status = 3`
|
||||
- 거절 시: `maestro_upgrade.Status = 2`
|
||||
|
||||
---
|
||||
|
||||
## 8. 개발 단계 계획
|
||||
|
||||
### Phase 1. 프로젝트 초기화 및 환경 구성
|
||||
|
||||
- [ ] Next.js 15 + TypeScript 프로젝트 생성
|
||||
- [ ] Tailwind CSS + shadcn/ui 설치 및 설정
|
||||
- [ ] TanStack Table 설치
|
||||
- [ ] mysql2 설치 및 DB 연결 유틸리티 작성
|
||||
- [ ] docker-compose.yml 작성 (개발용)
|
||||
- [ ] `.env.local` 환경변수 구성
|
||||
- [ ] NextAuth.js v5 설치 및 `admin` 테이블 기반 로그인 구현
|
||||
|
||||
### Phase 2. 공통 레이아웃 및 인증
|
||||
|
||||
- [ ] 로그인 페이지 (`/login`)
|
||||
- [ ] 인증 미들웨어 (비로그인 시 `/login` 리다이렉트)
|
||||
- [ ] 공통 레이아웃 (사이드바 네비게이션, 상단 헤더)
|
||||
|
||||
### Phase 3. 마에스트로 관리
|
||||
|
||||
- [ ] 마에스트로 전체 목록 페이지 + API
|
||||
- [ ] 마에스트로 상세 정보 페이지 + API
|
||||
|
||||
### Phase 4. 연장 신청 관리
|
||||
|
||||
- [ ] 연장 신청 목록 페이지 + API
|
||||
- [ ] 연장 승인 / 취소 기능 + API
|
||||
|
||||
### Phase 5. 업그레이드 신청 관리
|
||||
|
||||
- [ ] 업그레이드 신청 목록 페이지 + API
|
||||
- [ ] 업그레이드 승인 / 거절 기능 + API
|
||||
|
||||
### Phase 6. 배포 환경 구성
|
||||
|
||||
- [ ] Dockerfile (프로덕션용) 작성
|
||||
- [ ] docker-compose.prod.yml 작성 (Synology NAS용)
|
||||
- [ ] AWS EC2 DB 보안그룹 인바운드 설정 (NAS 외부 IP 허용)
|
||||
- [ ] Synology NAS Container Manager에서 배포 테스트
|
||||
|
||||
---
|
||||
|
||||
## 9. 디렉토리 구조 (목표)
|
||||
|
||||
```
|
||||
chocoadmin/
|
||||
├── app/
|
||||
│ ├── (auth)/
|
||||
│ │ └── login/
|
||||
│ ├── (admin)/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── maestro/
|
||||
│ │ ├── extension/
|
||||
│ │ └── upgrade/
|
||||
│ └── api/
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||
│ ├── data-table/ # TanStack Table 공통 컴포넌트
|
||||
│ └── layout/ # 사이드바, 헤더
|
||||
├── lib/
|
||||
│ ├── db.ts # mysql2 연결 풀
|
||||
│ └── utils.ts
|
||||
├── types/
|
||||
│ └── index.ts # DB 타입 정의
|
||||
├── docs/
|
||||
│ └── db-reference/
|
||||
│ └── schema.sql
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.prod.yml
|
||||
├── Dockerfile
|
||||
├── .env.local # (gitignore)
|
||||
└── .env.production # (gitignore)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 보안 고려사항
|
||||
|
||||
- 관리자 전용 서비스이므로 외부 노출을 최소화 (NAS 내부 포트 또는 VPN 기반 접근 권장)
|
||||
- `admin.Password`는 해시 검증 방식으로 NextAuth 연동 (기존 암호화 방식 확인 필요)
|
||||
- DB 연결 정보는 `.env` 파일로 관리하며 Git에 포함하지 않음
|
||||
- AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS IP로만 허용
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-05-26*
|
||||
@@ -0,0 +1,501 @@
|
||||
# chocoadmin 개발 작업 계획
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
`chocoadmin`은 기존 `chocomae` 웹 서비스의 관리자 페이지와 관리자 기능만 독립 분리하는 신규 관리자 웹 서비스이다.
|
||||
|
||||
- 기존 서비스명: `chocomae`
|
||||
- 기존 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
|
||||
- 신규 서비스명: `chocoadmin`
|
||||
- 신규 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/chocoadmin`
|
||||
- 개발 목표: 기존 `mouse-typing` 서비스의 관리자 기능을 Next.js 기반 독립 서비스로 재구성
|
||||
- 기존 운영 환경: AWS EC2 `t3a.medium` 인스턴스 1개
|
||||
- 신규 개발/운영 플랫폼: Synology NAS Docker
|
||||
- 보유 NAS: Synology DS920+, RAM 20GB
|
||||
|
||||
## 2. 핵심 개발 방향
|
||||
|
||||
1. 기존 `chocomae` DB를 그대로 사용한다.
|
||||
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
|
||||
3. 운영 DB 스키마를 임의 변경하지 않고, 기존 테이블과 상태값을 우선 존중한다.
|
||||
4. 관리자 액션은 반드시 서버 사이드에서 권한 검증, 트랜잭션, 로그 기록을 거친다.
|
||||
5. Synology NAS Docker에서 개발과 운영을 모두 재현 가능하게 구성한다.
|
||||
|
||||
## 3. 기술 스택 제안
|
||||
|
||||
### 기본 스택
|
||||
|
||||
- Runtime: Node.js LTS
|
||||
- Framework: Next.js App Router
|
||||
- Language: TypeScript
|
||||
- Database: MariaDB
|
||||
- DB Access: Prisma 또는 Drizzle ORM
|
||||
- Auth: NextAuth/Auth.js 또는 자체 세션 기반 인증
|
||||
- Validation: Zod
|
||||
- Package Manager: pnpm
|
||||
- Container: Docker, Docker Compose
|
||||
|
||||
### DB 접근 방식 추천
|
||||
|
||||
1. Prisma
|
||||
- 장점: 타입 안정성, 마이그레이션/스키마 관리 경험이 좋고 개발 속도가 빠름
|
||||
- 주의: 기존 MariaDB 스키마를 introspection해서 사용하는 방식이 적합함
|
||||
|
||||
2. Drizzle ORM
|
||||
- 장점: SQL에 가까운 제어, 가벼움, 서버 액션/API Route와 잘 맞음
|
||||
- 주의: 기존 DB 테이블 정의를 직접 코드로 옮기는 작업이 필요함
|
||||
|
||||
권장안: 초기 개발 생산성과 유지보수를 고려하면 Prisma를 우선 검토하고, 기존 DB 쿼리를 세밀하게 제어해야 하는 구간만 raw SQL을 병행한다.
|
||||
|
||||
## 4. CSS Framework 추천
|
||||
|
||||
관리자 서비스는 표, 검색, 필터, 상세 보기, 승인/거절 액션이 중심이므로 화려한 랜딩 페이지보다 유지보수성과 일관성이 중요하다.
|
||||
|
||||
### 1순위: Tailwind CSS + shadcn/ui
|
||||
|
||||
- 장점: Next.js와 궁합이 좋고, 관리자 화면에 필요한 버튼, 입력, 다이얼로그, 탭, 폼 구성이 빠름
|
||||
- 장점: 컴포넌트 코드를 프로젝트 안에서 직접 소유하므로 장기 유지보수에 유리함
|
||||
- 단점: Tailwind 클래스에 익숙해지는 시간이 필요함
|
||||
- 추천 이유: `chocoadmin`처럼 작은 팀/개인 프로젝트에서 빠르게 만들고 나중에 고치기 좋음
|
||||
|
||||
### 2순위: Mantine
|
||||
|
||||
- 장점: 컴포넌트가 풍부하고 Form, Modal, Table 주변 UI를 빠르게 만들기 좋음
|
||||
- 장점: 테마 시스템과 훅이 잘 갖춰져 있음
|
||||
- 단점: 디자인을 많이 커스터마이징하면 프레임워크 스타일에 묶일 수 있음
|
||||
- 추천 이유: 관리자 도구를 빠르게 안정적인 UI로 완성하기 좋음
|
||||
|
||||
### 3순위: Chakra UI
|
||||
|
||||
- 장점: 접근성, 컴포넌트 API, 테마 관리가 안정적임
|
||||
- 장점: Next.js 프로젝트에서 사용 사례가 많음
|
||||
- 단점: 대량 데이터 테이블은 별도 라이브러리 조합이 필요함
|
||||
- 추천 이유: 익숙한 컴포넌트 기반 개발을 선호할 때 적합함
|
||||
|
||||
최종 권장안: `Tailwind CSS + shadcn/ui`를 기본 UI 프레임워크로 사용한다.
|
||||
|
||||
## 5. Table 라이브러리 추천
|
||||
|
||||
관리자 화면은 마에스트로 목록, 연장 신청, 업그레이드 신청처럼 대부분 표 기반으로 운영되므로 정렬, 필터, 검색, 페이지네이션, 행 선택, 컬럼 표시 제어, 액션 버튼 확장이 쉬워야 한다.
|
||||
|
||||
### 1순위: TanStack Table
|
||||
|
||||
- 장점: headless 방식이라 UI 프레임워크와 자유롭게 결합 가능
|
||||
- 장점: 서버 사이드 페이지네이션, 정렬, 필터링을 구현하기 좋음
|
||||
- 장점: shadcn/ui의 Data Table 패턴과 잘 맞음
|
||||
- 단점: 직접 조립해야 하는 부분이 있어 초기 구현량이 있음
|
||||
- 추천 이유: 관리자 기능이 늘어날 가능성이 높은 `chocoadmin`에 가장 유연함
|
||||
|
||||
### 2순위: AG Grid Community
|
||||
|
||||
- 장점: 엑셀 같은 고급 그리드 기능이 강함
|
||||
- 장점: 컬럼 리사이즈, 고정 컬럼, 복잡한 필터 등 고급 기능이 풍부함
|
||||
- 단점: UI가 무겁고, 일부 기능은 Enterprise 라이선스가 필요함
|
||||
- 추천 이유: 향후 대량 데이터 운영 도구가 필요해질 때 적합함
|
||||
|
||||
### 3순위: Mantine React Table
|
||||
|
||||
- 장점: Mantine을 CSS/UI 프레임워크로 선택할 경우 통합 경험이 좋음
|
||||
- 장점: TanStack Table 기반이라 기능 확장성이 좋음
|
||||
- 단점: Mantine 생태계에 묶임
|
||||
- 추천 이유: Mantine을 선택하는 경우 가장 빠르게 관리자 표를 만들 수 있음
|
||||
|
||||
최종 권장안: `TanStack Table + shadcn/ui Data Table` 조합을 사용한다.
|
||||
|
||||
## 6. DB 기준 관리자 대상 테이블
|
||||
|
||||
스키마 파일: `/Users/jisangs/work/jinaju/git/web/chocoadmin/docs/db-reference/schema.sql`
|
||||
|
||||
### 주요 테이블
|
||||
|
||||
- `admin`: 관리자 계정
|
||||
- `maestro`: 마에스트로 계정
|
||||
- `maestro_extension`: 마에스트로 연장 신청
|
||||
- `maestro_upgrade`: 마에스트로 업그레이드 신청
|
||||
- `maestro_log`: 마에스트로 관련 처리 로그
|
||||
- `player`: 마에스트로에 속한 학생/플레이어
|
||||
|
||||
### 주요 상태값
|
||||
|
||||
`maestro.ActivateStatus`
|
||||
|
||||
- `0`: 미승인
|
||||
- `1`: 체험
|
||||
- `2`: 활성화
|
||||
- `100`: 등록취소
|
||||
|
||||
`maestro.AccountType`
|
||||
|
||||
- `1`: 20명
|
||||
- `2`: 50명
|
||||
- `3`: 100명
|
||||
- `4`: 500명
|
||||
- `5`: 1000명
|
||||
|
||||
`maestro_extension.Status`
|
||||
|
||||
- `1`: 요청
|
||||
- `2`: 취소
|
||||
- `3`: 연장 적용
|
||||
|
||||
`maestro_upgrade.Status`
|
||||
|
||||
- `1`: 요청
|
||||
- `2`: 취소
|
||||
- `3`: 업그레이드 적용
|
||||
|
||||
## 7. 주요 관리자 화면 계획
|
||||
|
||||
### 7.1 공통 레이아웃
|
||||
|
||||
- 로그인 화면
|
||||
- 좌측 사이드바
|
||||
- 상단 관리자 정보 영역
|
||||
- 공통 검색/필터 패턴
|
||||
- 공통 확인 다이얼로그
|
||||
- 처리 성공/실패 토스트
|
||||
- 접근 권한 없는 경우 안내 화면
|
||||
|
||||
### 7.2 마에스트로 전체 목록
|
||||
|
||||
목표: 전체 마에스트로 계정을 검색, 필터링, 확인할 수 있는 운영 화면을 만든다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 전체 목록 조회
|
||||
- 이름, 이메일, `MaestroID` 검색
|
||||
- 계정 종류 필터
|
||||
- 활성 상태 필터
|
||||
- 가입/약관 동의/사용 가능 기간 기준 정렬
|
||||
- 학생 수 표시
|
||||
- 상세 화면 이동
|
||||
|
||||
표 주요 컬럼:
|
||||
|
||||
- `MaestroID`
|
||||
- `Name`
|
||||
- `Email`
|
||||
- `AccountType`
|
||||
- `ActivateStatus`
|
||||
- `PlayerCount`
|
||||
- `AvailableActivateDateTime`
|
||||
- `AcceptClausesDateTime`
|
||||
- `AllowEditEnterCode`
|
||||
|
||||
### 7.3 마에스트로 상세 정보 보기
|
||||
|
||||
목표: 특정 마에스트로의 계정 상태와 관련 신청 이력을 한 화면에서 확인한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 기본 계정 정보
|
||||
- 현재 계정 종류/활성 상태 표시
|
||||
- 학생 수 및 플레이어 목록 요약
|
||||
- 최근 연장 신청 이력
|
||||
- 최근 업그레이드 신청 이력
|
||||
- 처리 로그 표시
|
||||
|
||||
확장 후보 기능:
|
||||
|
||||
- 계정 상태 수동 변경
|
||||
- 학생 목록 상세 조회
|
||||
- 입장번호 변경 허용 여부 수정
|
||||
|
||||
단, 확장 후보 기능은 기존 관리자 기능과 운영 정책 확인 후 별도 구현한다.
|
||||
|
||||
### 7.4 마에스트로 연장 신청 목록
|
||||
|
||||
목표: `maestro_extension`의 연장 요청을 검토하고 승인 또는 취소 처리한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 연장 신청 목록 조회
|
||||
- 요청 상태별 필터
|
||||
- 요청일 기준 정렬
|
||||
- 마에스트로 정보 함께 표시
|
||||
- 연장 승인
|
||||
- 연장 취소
|
||||
- 처리 후 `maestro_log` 기록
|
||||
|
||||
승인 처리 초안:
|
||||
|
||||
1. `maestro_extension.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_extension.Status`를 `3`으로 변경한다.
|
||||
4. `maestro.AccountType`, `AvailableActivateDateTime`, `ActivateStatus` 갱신 규칙을 기존 서비스에서 확인해 동일하게 적용한다.
|
||||
5. `maestro_log`에 처리 기록을 남긴다.
|
||||
6. 트랜잭션을 커밋한다.
|
||||
|
||||
취소 처리 초안:
|
||||
|
||||
1. `maestro_extension.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_extension.Status`를 `2`로 변경한다.
|
||||
4. `maestro_log`에 취소 기록을 남긴다.
|
||||
5. 트랜잭션을 커밋한다.
|
||||
|
||||
### 7.5 마에스트로 업그레이드 신청 목록
|
||||
|
||||
목표: `maestro_upgrade`의 업그레이드 요청을 검토하고 승인 또는 거절 처리한다.
|
||||
|
||||
주요 기능:
|
||||
|
||||
- 업그레이드 신청 목록 조회
|
||||
- 요청 상태별 필터
|
||||
- 현재 계정 종류와 요청 계정 종류 비교 표시
|
||||
- 요청일 기준 정렬
|
||||
- 업그레이드 승인
|
||||
- 업그레이드 거절
|
||||
- 처리 후 `maestro_log` 기록
|
||||
|
||||
승인 처리 초안:
|
||||
|
||||
1. `maestro_upgrade.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_upgrade.Status`를 `3`으로 변경한다.
|
||||
4. `maestro.AccountType`을 `RequestedAccountType`으로 변경한다.
|
||||
5. 필요 시 `PlayerCount` 정책을 기존 서비스에서 확인해 반영한다.
|
||||
6. `maestro_log`에 처리 기록을 남긴다.
|
||||
7. 트랜잭션을 커밋한다.
|
||||
|
||||
거절 처리 초안:
|
||||
|
||||
1. `maestro_upgrade.Status = 1`인지 확인한다.
|
||||
2. 트랜잭션을 시작한다.
|
||||
3. `maestro_upgrade.Status`를 `2`로 변경한다.
|
||||
4. `maestro_log`에 거절 기록을 남긴다.
|
||||
5. 트랜잭션을 커밋한다.
|
||||
|
||||
## 8. 애플리케이션 구조 제안
|
||||
|
||||
```text
|
||||
chocoadmin/
|
||||
app/
|
||||
login/
|
||||
maestros/
|
||||
page.tsx
|
||||
[maestroId]/
|
||||
page.tsx
|
||||
extension-requests/
|
||||
page.tsx
|
||||
upgrade-requests/
|
||||
page.tsx
|
||||
api/
|
||||
components/
|
||||
layout/
|
||||
data-table/
|
||||
forms/
|
||||
dialogs/
|
||||
lib/
|
||||
auth/
|
||||
db/
|
||||
validators/
|
||||
constants/
|
||||
docs/
|
||||
db-reference/
|
||||
schema.sql
|
||||
project-plan.md
|
||||
prisma/
|
||||
schema.prisma
|
||||
docker/
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
## 9. Docker 개발/운영 계획
|
||||
|
||||
### 개발 환경
|
||||
|
||||
- Synology NAS Docker에서 Next.js 컨테이너 실행
|
||||
- 개발 DB는 `localhost` MariaDB 사용
|
||||
- `.env.local`로 DB 접속 정보 관리
|
||||
- 개발 DB는 운영 DB 덤프에서 민감 정보를 제거한 샘플 데이터 사용 권장
|
||||
|
||||
### 운영 환경
|
||||
|
||||
- Synology NAS Docker에서 `chocoadmin` 컨테이너 실행
|
||||
- 운영 DB는 기존 AWS EC2 MariaDB에 접속
|
||||
- AWS 보안 그룹에서 Synology NAS 공인 IP만 MariaDB 접속 허용
|
||||
- DB 계정은 관리자 서비스 전용 계정으로 분리
|
||||
- 최소 권한 원칙 적용
|
||||
- 운영 배포 시 환경변수는 Synology Container Manager 또는 `.env.production`으로 관리
|
||||
|
||||
### Docker 구성 초안
|
||||
|
||||
- `Dockerfile`: Next.js production build
|
||||
- `docker-compose.yml`: app service 정의
|
||||
- `healthcheck`: HTTP 상태 확인 엔드포인트 사용
|
||||
- `restart: unless-stopped`
|
||||
- 로그는 Docker 기본 로그 또는 Synology 로그 수집 방식 사용
|
||||
|
||||
## 10. 보안 계획
|
||||
|
||||
1. 관리자 로그인은 서버 세션 기반으로 구현한다.
|
||||
2. 관리자 비밀번호 저장 방식은 기존 `admin.Password` 구조를 확인한 뒤 개선 가능성을 검토한다.
|
||||
3. 운영 DB 접속 정보는 저장소에 커밋하지 않는다.
|
||||
4. 승인/취소/거절 같은 변경 액션은 CSRF 또는 서버 액션 보호를 적용한다.
|
||||
5. 관리자 액션은 `maestro_log`에 기록한다.
|
||||
6. 운영 DB 직접 수정 기능은 최소화한다.
|
||||
7. 승인/거절 API는 중복 처리 방지를 위해 현재 상태를 조건으로 검사한다.
|
||||
8. 모든 변경 작업은 트랜잭션으로 처리한다.
|
||||
|
||||
## 11. 개발 단계별 작업 계획
|
||||
|
||||
### Phase 0. 기존 관리자 기능 조사
|
||||
|
||||
- 기존 `mouse-typing` 코드에서 관리자 라우트와 처리 로직 조사
|
||||
- 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인
|
||||
- 업그레이드 승인 시 `PlayerCount`와 `AccountType` 처리 방식 확인
|
||||
- 기존 `admin.Password` 검증 방식 확인
|
||||
- 기존 로그 기록 형식 확인
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 기존 관리자 처리 규칙을 문서화한다.
|
||||
- 신규 서비스에서 재현해야 할 비즈니스 규칙을 확정한다.
|
||||
|
||||
### Phase 1. 프로젝트 초기화
|
||||
|
||||
- Next.js + TypeScript 프로젝트 생성
|
||||
- ESLint, Prettier 설정
|
||||
- Tailwind CSS + shadcn/ui 설정
|
||||
- 기본 레이아웃 구성
|
||||
- 환경변수 구조 정의
|
||||
- Dockerfile, docker-compose 초안 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 로컬 또는 Synology Docker에서 기본 페이지가 실행된다.
|
||||
|
||||
### Phase 2. DB 연결 및 타입 구성
|
||||
|
||||
- MariaDB 연결 설정
|
||||
- Prisma introspection 또는 Drizzle schema 작성
|
||||
- DB 연결 헬퍼 구현
|
||||
- 상태값 상수화
|
||||
- 공통 날짜/계정 종류 표시 유틸 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회가 가능하다.
|
||||
|
||||
### Phase 3. 인증과 관리자 세션
|
||||
|
||||
- 로그인 화면 구현
|
||||
- 관리자 계정 검증
|
||||
- 세션 생성/만료 처리
|
||||
- 보호 라우트 처리
|
||||
- 로그아웃 구현
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 로그인하지 않은 사용자는 관리자 화면에 접근할 수 없다.
|
||||
- 로그인한 관리자만 목록과 상세 화면에 접근할 수 있다.
|
||||
|
||||
### Phase 4. 마에스트로 목록/상세 화면
|
||||
|
||||
- 마에스트로 목록 API 또는 서버 액션 구현
|
||||
- TanStack Table 기반 목록 화면 구현
|
||||
- 검색, 필터, 정렬, 페이지네이션 구현
|
||||
- 상세 화면 구현
|
||||
- 상세 화면에서 연장/업그레이드/로그 요약 표시
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 운영자가 마에스트로를 빠르게 검색하고 상세 정보를 확인할 수 있다.
|
||||
|
||||
### Phase 5. 연장 신청 관리
|
||||
|
||||
- 연장 신청 목록 구현
|
||||
- 상태 필터와 검색 구현
|
||||
- 승인/취소 확인 다이얼로그 구현
|
||||
- 승인/취소 서버 액션 구현
|
||||
- 트랜잭션과 로그 기록 적용
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 요청 상태의 연장 신청을 승인 또는 취소할 수 있다.
|
||||
- 중복 처리와 이미 처리된 건의 재처리가 방지된다.
|
||||
|
||||
### Phase 6. 업그레이드 신청 관리
|
||||
|
||||
- 업그레이드 신청 목록 구현
|
||||
- 현재/요청 계정 비교 표시
|
||||
- 승인/거절 확인 다이얼로그 구현
|
||||
- 승인/거절 서버 액션 구현
|
||||
- 트랜잭션과 로그 기록 적용
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 요청 상태의 업그레이드 신청을 승인 또는 거절할 수 있다.
|
||||
- 처리 결과가 마에스트로 계정에 정확히 반영된다.
|
||||
|
||||
### Phase 7. 운영 배포 준비
|
||||
|
||||
- Synology NAS Docker Compose 운영 설정 작성
|
||||
- 운영 환경변수 정리
|
||||
- AWS EC2 MariaDB 접근 보안 그룹 설정 가이드 작성
|
||||
- 백업/롤백 절차 작성
|
||||
- 관리자 액션 테스트 체크리스트 작성
|
||||
|
||||
완료 기준:
|
||||
|
||||
- Synology NAS에서 운영 컨테이너를 실행할 수 있다.
|
||||
- 운영 DB 연결 전 점검 항목이 문서화된다.
|
||||
|
||||
### Phase 8. 검증 및 안정화
|
||||
|
||||
- 주요 화면 수동 테스트
|
||||
- 승인/취소/거절 트랜잭션 테스트
|
||||
- 권한 없는 접근 테스트
|
||||
- Docker 재시작 테스트
|
||||
- 운영 DB read-only 리허설 후 실제 변경 기능 활성화
|
||||
|
||||
완료 기준:
|
||||
|
||||
- 핵심 관리자 업무가 신규 서비스에서 안정적으로 수행된다.
|
||||
|
||||
## 12. 우선 확인이 필요한 질문
|
||||
|
||||
1. 기존 `mouse-typing` 관리자 코드에서 연장 승인 시 정확히 며칠/몇 개월을 연장하는가?
|
||||
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
|
||||
3. `admin.Password`는 평문, 해시, 자체 암호화 중 어떤 방식인가?
|
||||
4. 운영 MariaDB가 외부 접속을 허용할 수 있는 네트워크 구조인가?
|
||||
5. 관리자 계정은 기존 `admin` 테이블을 그대로 사용할 것인가, 신규 인증 테이블을 둘 것인가?
|
||||
6. 운영 초기에 읽기 전용 모드로 먼저 배포한 뒤 변경 기능을 켤 것인가?
|
||||
|
||||
## 13. 추천 MVP 범위
|
||||
|
||||
1차 MVP는 아래 기능까지만 포함한다.
|
||||
|
||||
- 관리자 로그인
|
||||
- 마에스트로 전체 목록
|
||||
- 마에스트로 상세 정보 보기
|
||||
- 연장 신청 목록
|
||||
- 연장 승인/취소
|
||||
- 업그레이드 신청 목록
|
||||
- 업그레이드 승인/거절
|
||||
- 관리자 액션 로그 기록
|
||||
- Docker 기반 실행
|
||||
|
||||
MVP 이후 확장 후보:
|
||||
|
||||
- 관리자 계정 관리
|
||||
- 마에스트로 상태 직접 변경
|
||||
- 학생 목록 상세 관리
|
||||
- 데이터 내보내기 CSV
|
||||
- 처리 통계 대시보드
|
||||
- 감사 로그 전용 화면
|
||||
|
||||
## 14. 최종 추천 조합
|
||||
|
||||
- Framework: Next.js App Router
|
||||
- Language: TypeScript
|
||||
- UI/CSS: Tailwind CSS + shadcn/ui
|
||||
- Table: TanStack Table
|
||||
- DB: MariaDB 기존 `chocomae` DB
|
||||
- ORM: Prisma 우선 검토
|
||||
- Validation: Zod
|
||||
- Runtime/Deploy: Docker on Synology NAS
|
||||
- 운영 DB 연결: AWS EC2 MariaDB에 제한된 IP와 전용 계정으로 접속
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
# chocoadmin 프로젝트 계획서
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 서비스명 | chocoadmin |
|
||||
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
|
||||
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
|
||||
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
|
||||
| 기존 운영 환경 | AWS EC2 t3a.medium 인스턴스 1개 |
|
||||
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 개발 방향
|
||||
|
||||
1. 기존 `chocomae` DB를 그대로 사용한다. DB 스키마는 임의로 변경하지 않는다.
|
||||
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
|
||||
3. 기존 테이블 구조와 상태값을 그대로 존중하고, 기존 서비스의 비즈니스 규칙을 먼저 파악한 뒤 동일하게 구현한다.
|
||||
4. 승인·취소·거절 같은 변경 액션은 반드시 서버 사이드에서 권한 검증 → 트랜잭션 → 로그 기록 순서로 처리한다.
|
||||
5. 중복 처리 방지를 위해 모든 변경 API는 현재 상태를 조건으로 먼저 검사한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 기술 스택
|
||||
|
||||
| 구분 | 기술 |
|
||||
|---|---|
|
||||
| 런타임 | Node.js LTS |
|
||||
| 프레임워크 | Next.js 16 (App Router) |
|
||||
| 언어 | TypeScript |
|
||||
| 패키지 매니저 | pnpm |
|
||||
| UI / CSS | Tailwind CSS + shadcn/ui |
|
||||
| Table | TanStack Table (React Table v8) |
|
||||
| DB 클라이언트 | mysql2 (MariaDB 호환) |
|
||||
| ORM | Prisma (기존 DB introspection 방식으로 활용) |
|
||||
| 유효성 검증 | Zod |
|
||||
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
|
||||
| 이메일 발송 | Nodemailer (기존 `mouse-typing` SMTP 설정을 환경변수로 이관) |
|
||||
| 환경변수 관리 | `.env.local` / `.env.production` |
|
||||
|
||||
### Prisma vs Drizzle ORM
|
||||
|
||||
두 가지 모두 MariaDB를 지원하나, 이 프로젝트에서는 **Prisma**를 우선 사용한다.
|
||||
|
||||
- 기존 DB를 `prisma db pull`(introspection)로 그대로 가져올 수 있어 초기 세팅이 빠름
|
||||
- TypeScript 타입 자동 생성으로 컬럼명 오타를 컴파일 단계에서 잡을 수 있음
|
||||
- 세밀한 SQL 제어가 필요한 구간은 `$queryRaw` / `$executeRaw`로 raw SQL 병행 가능
|
||||
|
||||
---
|
||||
|
||||
## 4. 개발 환경 및 배포 환경
|
||||
|
||||
### 4-1. 개발 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 하드웨어 | 맥미니 |
|
||||
| IDE | IntelliJ IDEA |
|
||||
| 실행 방법 | IntelliJ 터미널에서 `pnpm dev` |
|
||||
| 포트 | 3000 |
|
||||
| DB (개발) | 맥미니 로컬 MariaDB (`localhost:3306`) |
|
||||
| 환경변수 | `.env.local` |
|
||||
|
||||
### 4-2. 배포(서비스) 환경
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 플랫폼 | Synology NAS DS920+ (RAM 20GB) — Docker |
|
||||
| 실행 방법 | Synology Container Manager (`docker compose up`) |
|
||||
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
|
||||
| DB 접근 | AWS 보안그룹에서 NAS 외부 IP만 3306 포트 허용 |
|
||||
| DB 계정 | chocoadmin 전용 최소 권한 계정으로 분리 |
|
||||
| 환경변수 | `.env.production` 또는 Container Manager 환경변수 설정 |
|
||||
|
||||
### 4-3. Dockerfile / docker-compose (배포용)
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:22-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
CMD ["pnpm", "start"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
chocoadmin:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://USER:PASS@AWS_EC2_HOST:3306/chocomae
|
||||
- NEXTAUTH_SECRET=...
|
||||
- NEXTAUTH_URL=http://NAS_IP:3000
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터베이스 주요 테이블
|
||||
|
||||
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
|
||||
|
||||
| 테이블 | 설명 | 주요 컬럼 |
|
||||
|---|---|---|
|
||||
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `PlayerCount` |
|
||||
| `maestro_extension` | 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_upgrade` | 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
|
||||
| `maestro_log` | 처리 이력 로그 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
|
||||
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
|
||||
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
|
||||
|
||||
### 상태값 코드표
|
||||
|
||||
**`maestro.AccountType`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 20명 (1만원) |
|
||||
| 2 | 50명 (2만원) |
|
||||
| 3 | 100명 (3만원) |
|
||||
| 4 | 500명 (4만원) |
|
||||
| 5 | 1,000명 (5만원) |
|
||||
|
||||
**`maestro.ActivateStatus`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 0 | 미승인 |
|
||||
| 1 | 체험 |
|
||||
| 2 | 활성화 |
|
||||
| 100 | 등록취소 |
|
||||
|
||||
**`maestro_extension.Status` / `maestro_upgrade.Status`**
|
||||
|
||||
| 값 | 의미 |
|
||||
|---|---|
|
||||
| 1 | 요청 |
|
||||
| 2 | 취소/거절 |
|
||||
| 3 | 적용 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 애플리케이션 구조
|
||||
|
||||
```
|
||||
chocoadmin/
|
||||
├── app/
|
||||
│ ├── (auth)/
|
||||
│ │ └── login/
|
||||
│ │ └── page.tsx # 관리자 로그인
|
||||
│ ├── (admin)/
|
||||
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
|
||||
│ │ ├── MobileNav.tsx # 모바일 슬라이드인 사이드바 (햄버거·오버레이·X 버튼)
|
||||
│ │ ├── nav-config.ts # 내비게이션 항목 공유 설정
|
||||
│ │ ├── maestros/
|
||||
│ │ │ ├── page.tsx # 마에스트로 전체 목록
|
||||
│ │ │ └── [maestroId]/
|
||||
│ │ │ ├── page.tsx # 마에스트로 상세 정보 (server)
|
||||
│ │ │ ├── MaestroEditForm.tsx # 기본 정보 수정 폼 (client)
|
||||
│ │ │ ├── StudentsSection.tsx # 학생 목록 + 검색/페이지네이션 (client)
|
||||
│ │ │ ├── ExtensionRequestsSection.tsx # 연장 신청 이력 + [연장] 버튼 (client)
|
||||
│ │ │ └── UpgradeRequestsSection.tsx # 업그레이드 신청 이력 + 신청 UI (client)
|
||||
│ │ ├── extension-requests/
|
||||
│ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소
|
||||
│ │ └── upgrade-requests/
|
||||
│ │ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
|
||||
│ └── api/
|
||||
│ ├── auth/[...nextauth]/
|
||||
│ ├── maestros/
|
||||
│ │ ├── route.ts # GET: 목록
|
||||
│ │ └── [id]/
|
||||
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
|
||||
│ │ ├── students/route.ts # GET: 학생 목록
|
||||
│ │ ├── extension-requests/route.ts # POST: 연장 신청 등록 (관리자 직접)
|
||||
│ │ ├── upgrade-requests/route.ts # POST: 업그레이드 신청 등록 (관리자 직접)
|
||||
│ │ └── reset-password/route.ts # POST: 비밀번호 123456 초기화
|
||||
│ ├── extension-requests/
|
||||
│ │ └── [id]/route.ts # PATCH: 승인/취소
|
||||
│ └── upgrade-requests/
|
||||
│ └── [id]/route.ts # PATCH: 승인/거절
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui 컴포넌트
|
||||
│ ├── data-table/ # TanStack Table 공통 컴포넌트
|
||||
│ ├── layout/ # 사이드바, 헤더
|
||||
│ ├── forms/
|
||||
│ └── dialogs/ # 승인/취소 확인 다이얼로그
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma Client 싱글톤
|
||||
│ ├── auth.ts # NextAuth 설정
|
||||
│ ├── mail.ts # chocomae SMTP 메일 발송 모듈
|
||||
│ ├── validators/ # Zod 스키마
|
||||
│ └── constants.ts # 상태값 상수
|
||||
├── types/
|
||||
│ └── index.ts
|
||||
├── prisma/
|
||||
│ └── schema.prisma # DB introspection 결과
|
||||
├── docs/
|
||||
│ └── db-reference/
|
||||
│ └── schema.sql
|
||||
├── .env.local # gitignore
|
||||
├── .env.production # gitignore
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 주요 화면 기능 명세
|
||||
|
||||
### 7-1. 공통 레이아웃
|
||||
|
||||
- 로그인 화면 (미인증 시 `/login` 리다이렉트)
|
||||
- 좌측 사이드바 네비게이션
|
||||
- 공통 확인 다이얼로그 (승인/취소/거절 전 확인)
|
||||
- 처리 성공/실패 토스트 알림
|
||||
|
||||
### 7-2. 마에스트로 전체 목록 (`/maestros`)
|
||||
|
||||
- 전체 목록 조회 (서버 사이드 페이지네이션)
|
||||
- 검색: 이름, 이메일, MaestroID
|
||||
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
|
||||
- 필터: AccountType (20명 / 50명 / 100명 / 500명 / 1,000명)
|
||||
- 정렬: 가입일, 사용 가능일
|
||||
- 컬럼: ID / 이름 / 이메일 / 계정 유형 / 상태 / 학생 수 / 사용 가능일 / 상세보기
|
||||
|
||||
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`)
|
||||
|
||||
- 기본 계정 정보 (이름, 이메일, 계정 유형, 상태, 만료일, 약관 동의일)
|
||||
- 기본 계정 정보 수정 및 저장
|
||||
- 이름: 저장 시 다른 마에스트로 계정에서 이미 사용 중인지 검사하고, 중복이면 저장 실패 처리
|
||||
- 이메일
|
||||
- 상태: 콤보박스 (`maestro.ActivateStatus`)
|
||||
- 계정유형: 콤보박스 (`maestro.AccountType`)
|
||||
- 사용 가능일 (`maestro.AvailableActivateDateTime`)
|
||||
- 입장코드 수정: 콤보박스 기반 입력/선택 UI
|
||||
- 정보 수정 및 저장 성공 시 `maestro_log`에 처리 이력 기록
|
||||
- 플레이어(학생) 전체 목록
|
||||
- 서버 사이드 페이지네이션
|
||||
- 이름 검색
|
||||
- 연장 신청 이력 + [연장] 버튼
|
||||
- 유료 요금제 계정에서만 버튼 활성화 (체험 계정 비활성)
|
||||
- 클릭 시 현재 `AccountType`으로 `maestro_extension` INSERT (서버에서 DB 조회)
|
||||
- 등록 후 이력 목록 즉시 갱신 (`router.refresh()`)
|
||||
- 업그레이드 신청 이력 + 요금제 콤보박스 + [업그레이드] 버튼
|
||||
- 콤보박스: 현재 요금제 이하 항목 비활성화, 상위 요금제만 선택 가능
|
||||
- 클릭 시 선택한 `requestedAccountType`으로 `maestro_upgrade` INSERT (트랜잭션 내 서버 조회)
|
||||
- 서버에서 `requestedAccountType > maestro.AccountType` 검증 (다운그레이드 차단)
|
||||
- 등록 후 이력 목록 즉시 갱신 (`router.refresh()`)
|
||||
- 관리자 처리 로그 (`maestro_log`)
|
||||
|
||||
### 7-4. 연장 신청 목록 (`/extension-requests`)
|
||||
|
||||
- 연장 신청 목록 조회 (기본 필터: Status=1 요청 건)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인 / 취소
|
||||
|
||||
**승인 처리 순서:**
|
||||
1. `maestro_extension.Status = 1` 인지 확인 (중복 처리 방지)
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_extension.Status → 3`
|
||||
4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용)
|
||||
5. `maestro_log` 기록
|
||||
6. 트랜잭션 커밋
|
||||
7. 커밋 후 연장 완료 이메일 자동 발송
|
||||
|
||||
**연장 완료 이메일:**
|
||||
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php:29`
|
||||
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정 유효기간 연장 완료`
|
||||
- 내용: 유효기간 +1년 연장 완료, 새 유효기간 안내
|
||||
|
||||
**취소 처리 순서:**
|
||||
1. `maestro_extension.Status = 1` 인지 확인
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_extension.Status → 2`
|
||||
4. `maestro_log` 기록
|
||||
5. 트랜잭션 커밋
|
||||
|
||||
### 7-5. 업그레이드 신청 목록 (`/upgrade-requests`)
|
||||
|
||||
- 업그레이드 신청 목록 조회 (기본 필터: Status=1 요청 건)
|
||||
- 필터: 상태 (요청 / 취소 / 적용)
|
||||
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인 / 거절
|
||||
|
||||
**승인 처리 순서:**
|
||||
1. `maestro_upgrade.Status = 1` 인지 확인 (중복 처리 방지)
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_upgrade.Status → 3`
|
||||
4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용)
|
||||
5. `maestro_log` 기록
|
||||
6. 트랜잭션 커밋
|
||||
7. 커밋 후 업그레이드 완료 이메일 자동 발송
|
||||
|
||||
**업그레이드 완료 이메일:**
|
||||
- 기존 호출 위치: `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php:30`
|
||||
- 제목: `[초코마에] {마에스트로명} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
|
||||
- 내용: 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년
|
||||
|
||||
**거절 처리 순서:**
|
||||
1. `maestro_upgrade.Status = 1` 인지 확인
|
||||
2. 트랜잭션 시작
|
||||
3. `maestro_upgrade.Status → 2`
|
||||
4. `maestro_log` 기록
|
||||
5. 트랜잭션 커밋
|
||||
|
||||
---
|
||||
|
||||
## 8. 보안 고려사항
|
||||
|
||||
1. 관리자 로그인은 NextAuth 서버 세션 기반으로 구현한다.
|
||||
2. 기존 `admin.Password` 저장 방식(평문/해시/자체 암호화)을 먼저 확인한 뒤 동일 방식 또는 해시 업그레이드를 결정한다.
|
||||
3. 승인·취소·거절 액션은 Next.js Server Action 또는 API Route에서 CSRF 보호를 적용한다.
|
||||
4. DB 연결 정보는 `.env` 파일로 관리하며 Git에 커밋하지 않는다.
|
||||
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
|
||||
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
|
||||
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
|
||||
8. SMTP/API 인증 정보, 발송자 주소, BCC 주소는 환경변수 또는 배포 시크릿으로 관리하고 코드에 하드코딩하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 개발 단계 계획
|
||||
|
||||
### Phase 0. 기존 관리자 코드 조사
|
||||
|
||||
> 기존 서비스 비즈니스 규칙을 파악해 재현 실수를 방지한다.
|
||||
|
||||
- [x] `mouse-typing` 관리자 라우트와 처리 로직 분석 (`src/web/admin/`, `src/web/server/admin/`)
|
||||
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인 (몇 개월/년 연장인지)
|
||||
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부 확인
|
||||
- [x] `admin.Password` 검증 방식 확인 (평문 / MD5 / bcrypt 등)
|
||||
- [x] `maestro_log` 기록 형식(`Type`, `Remark`) 확인
|
||||
- [x] 조사 결과를 `docs/business-rules.md`에 문서화
|
||||
|
||||
### Phase 1. 프로젝트 초기화
|
||||
|
||||
- [x] `pnpm create next-app` (Next.js 16, TypeScript, App Router, Tailwind CSS)
|
||||
- [x] shadcn/ui 초기화 (`pnpm dlx shadcn@latest init`)
|
||||
- [x] TanStack Table 설치 (`pnpm add @tanstack/react-table`)
|
||||
- [x] Prisma 설치 및 MariaDB introspection (`pnpm add prisma`, `prisma db pull`)
|
||||
- [x] Zod 설치 (`pnpm add zod`)
|
||||
- [x] NextAuth.js v5 설치
|
||||
- [x] ESLint / Prettier 설정
|
||||
- [x] `.env.local` 환경변수 구조 정의
|
||||
- [x] IntelliJ에서 `pnpm dev` 실행 확인
|
||||
|
||||
### Phase 2. DB 연결 및 타입 구성
|
||||
|
||||
- [x] Prisma Client 싱글톤 (`lib/db.ts`)
|
||||
- [x] 상태값 상수 정의 (`lib/constants.ts`)
|
||||
- [x] 공통 유틸 작성 (날짜 포맷, AccountType 레이블 변환 등)
|
||||
- [x] 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회 확인
|
||||
|
||||
### Phase 3. 인증 및 공통 레이아웃
|
||||
|
||||
- [x] 로그인 페이지 (`/login`)
|
||||
- [x] NextAuth Credentials Provider — `admin` 테이블 기반 검증
|
||||
- [x] 인증 프록시 (`proxy.ts`) — 비로그인 시 `/login` 리다이렉트
|
||||
- [x] 공통 레이아웃 (`(admin)/layout.tsx`) — 사이드바, 헤더
|
||||
- [x] 로그아웃 기능
|
||||
|
||||
### Phase 4. 마에스트로 목록 및 상세
|
||||
|
||||
- [x] 마에스트로 목록 API (`GET /api/maestros`) — 검색·필터·페이지네이션 포함
|
||||
- [x] TanStack Table 기반 목록 화면
|
||||
- [x] 마에스트로 상세 API (`GET /api/maestros/[id]`)
|
||||
- [x] 상세 화면 — 연장/업그레이드 이력, 학생 목록 요약, 처리 로그
|
||||
|
||||
### Phase 5. 연장 신청 관리
|
||||
|
||||
- [x] 연장 신청 목록 API + 화면
|
||||
- [x] 승인/취소 확인 다이얼로그
|
||||
- [x] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log
|
||||
- [x] 취소 API — 트랜잭션 + maestro_log
|
||||
|
||||
### Phase 6. 업그레이드 신청 관리
|
||||
|
||||
- [x] 업그레이드 신청 목록 API + 화면
|
||||
- [x] 승인/거절 확인 다이얼로그
|
||||
- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
|
||||
- [x] 거절 API — 트랜잭션 + maestro_log
|
||||
|
||||
### Phase 7. 배포 환경 구성
|
||||
|
||||
- [x] Dockerfile (프로덕션용 pnpm 빌드)
|
||||
- [x] `docker-compose.yml` (Synology NAS Container Manager용)
|
||||
- [x] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용)
|
||||
- [x] Synology NAS에서 컨테이너 실행 확인 (production + stage 동시 운영 중)
|
||||
- [x] 운영 DB 연결 및 변경 기능 확인
|
||||
|
||||
### Phase 8. 검증 및 안정화
|
||||
|
||||
- [x] 승인/취소/거절 트랜잭션 시나리오 테스트 절차 문서화 (`docs/phase/phase8.md`)
|
||||
- [x] 중복 처리 방지 코드 확인 (이미 처리된 건 재처리 시 `409` 응답)
|
||||
- [x] 비인증 접근 차단 코드 확인 (`proxy.ts`, API route `auth()` 검사)
|
||||
- [x] Docker 재시작 후 정상 동작 확인 절차 문서화 (`docs/phase/phase8.md`)
|
||||
|
||||
### Phase 9. 실행 로그 및 DB 쿼리 로그
|
||||
|
||||
> local/stage에서는 개발과 디버깅에 충분한 상세 로그를 남기고, production에서는 운영에 필요한 수준으로 로그 양과 민감정보 노출을 제한한다.
|
||||
|
||||
- [x] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`)
|
||||
- [x] 환경 구분: `local`, `stage`, `production`
|
||||
- [x] 로그 레벨 구분: `debug`, `info`, `warn`, `error`
|
||||
- [x] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조
|
||||
- [x] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드
|
||||
- [x] 서버 실행 로그 유틸 구현
|
||||
- [x] 공통 logger 모듈 추가 (`lib/logger.ts`)
|
||||
- [ ] request 단위 correlation id 생성 및 로그에 포함 (보류 — Edge Middleware 제약으로 헤더 주입 방식 필요, `docs/phase/phase9.md` 참고)
|
||||
- [x] API route 시작/종료, 처리 시간, status code, action 결과 기록 (`lib/api-handler.ts` `withApiHandler` 래퍼로 공통 처리)
|
||||
- [x] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 (`ApiError` + `withApiHandler`)
|
||||
- [x] DB query 로그 구현
|
||||
- [x] Prisma query event 기반 query 로깅 적용 (`lib/db.ts` `$on("query", ...)`)
|
||||
- [x] local/stage: query문, params, duration, 결과를 `sql-formatter`로 pretty 출력 (`$on` + `$extends.$allOperations`)
|
||||
- [x] production: `LOG_LEVEL=info` 설정 시 debug 로그 전체 억제 — slow query만 warn으로 출력
|
||||
- [x] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`, 기본 1000ms)
|
||||
- [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 (미구현)
|
||||
- [x] 환경변수 추가
|
||||
- [x] `APP_ENV=local|stage|production`
|
||||
- [x] `LOG_LEVEL=debug|info|warn|error`
|
||||
- [ ] `DB_QUERY_LOG=off|summary|full` (미구현 — 현재는 `LOG_LEVEL`로 제어)
|
||||
- [x] `DB_SLOW_QUERY_MS`
|
||||
- [ ] `DB_LOG_RESULT_LIMIT` (미구현)
|
||||
- [x] 주요 API에 로그 적용
|
||||
- [x] 로그인 성공/실패 로그 (`auth.ts`)
|
||||
- [x] 마에스트로 목록/상세 조회 로그 (`lib/maestros.ts`)
|
||||
- [x] 연장 신청 조회/승인/취소 로그 (`lib/extension-requests.ts`, `app/api/extension-requests/[id]/route.ts`)
|
||||
- [x] 업그레이드 신청 조회/승인/거절 로그 (`lib/upgrade-requests.ts`, `app/api/upgrade-requests/[id]/route.ts`)
|
||||
- [x] 중복 처리 방지로 `409` 반환 시 warning 로그 (`withApiHandler`가 `ApiError` 캐치 후 warn 출력)
|
||||
- [x] Docker/NAS 로그 운영 절차 문서화 (`docs/phase/phase9.md`)
|
||||
- [x] `docker compose logs -f` 확인 방법
|
||||
- [x] stage와 production의 권장 `.env` 로그 설정 예시
|
||||
- [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차
|
||||
- [x] 검증
|
||||
- [x] local에서 query문과 결과 전체가 출력되는지 확인
|
||||
- [x] stage에서 query문과 결과 전체가 출력되는지 확인
|
||||
- [x] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인
|
||||
- [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략)
|
||||
|
||||
### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
|
||||
|
||||
> `/maestros/[maestroId]` 상세 화면에서 관리자 계정 정보 수정과 학생 목록 조회를 완성한다.
|
||||
|
||||
- [x] 기존 `mouse-typing` 마에스트로 정보 수정 로직 조사
|
||||
- [x] 이름 변경 시 중복 검사 기준 확인 (`maestro.Name`, 자기 자신 제외)
|
||||
- [x] 이메일 변경 처리와 로그 형식 확인
|
||||
- [x] 입장코드 수정 대상 컬럼과 기존 값 생성/검증 규칙 확인
|
||||
- [x] `maestro_log.Type` / `Remark` 형식을 기존 서비스와 맞춤
|
||||
- [x] 마에스트로 정보 수정 API 구현 (`PATCH /api/maestros/[id]`)
|
||||
- [x] Zod 요청 스키마 정의: 이름, 이메일, 상태, 계정유형, 사용 가능일, 입장코드
|
||||
- [x] 관리자 인증 및 권한 검증
|
||||
- [x] 이름 중복 검사: 다른 계정이 사용 중이면 저장 실패 응답
|
||||
- [x] 트랜잭션으로 `maestro` 정보 수정
|
||||
- [x] 변경 전/후 값을 포함해 `maestro_log` 기록
|
||||
- [x] 변경 없음, 유효하지 않은 상태값, 존재하지 않는 마에스트로에 대한 응답 정책 정리
|
||||
- [x] 마에스트로 상세 화면 수정 폼 구현
|
||||
- [x] 이름/이메일 입력
|
||||
- [x] 상태 콤보박스
|
||||
- [x] 계정유형 콤보박스
|
||||
- [x] 사용 가능일 입력
|
||||
- [x] 입장코드 수정 콤보박스
|
||||
- [x] 저장 전 확인, 저장 중 비활성화, 성공/실패 토스트
|
||||
- [x] 학생 목록 API 구현 (`GET /api/maestros/[id]/students`)
|
||||
- [x] 전체 학생 목록 조회
|
||||
- [x] 서버 사이드 페이지네이션
|
||||
- [x] 이름 검색
|
||||
- [x] 정렬 기준과 기본 page size 정의
|
||||
- [x] 학생 목록 UI 구현
|
||||
- [x] 이름 검색 입력
|
||||
- [x] 페이지네이션 컨트롤
|
||||
- [x] 빈 목록/검색 결과 없음 상태
|
||||
- [x] 검증
|
||||
- [x] 이름 중복 저장 실패 확인
|
||||
- [x] 정보 수정 성공 시 DB 반영과 `maestro_log` 기록 확인
|
||||
- [x] 학생 목록 페이지네이션/검색 동작 확인
|
||||
- [x] 비인증 접근 차단 확인
|
||||
|
||||
### Phase 11. 이메일 발송 환경 및 자동 발송
|
||||
|
||||
> 기존 `chocomae` 서비스의 관리자 승인 완료 메일을 `chocoadmin`에서도 동일하게 발송한다.
|
||||
> 상세 계획: `docs/phase/phase11.md`
|
||||
|
||||
- [x] 기존 이메일 발송 설정 이관 준비
|
||||
- [x] 기존 서비스 파일 확인: `send_jinaju_mail.php` — SMTP gmail:465, SMTPS, 발송자/BCC 주소 확인
|
||||
- [x] 기존 서비스 파일 확인: `mail_contents.php` — 연장/업그레이드 완료 이메일 제목·본문 확인
|
||||
- [x] 기존 서비스 파일 확인: `mail_setting.php` — From, BCC, sender_name 확인
|
||||
- [x] `.env.local`에 메일 환경변수 추가 (SMTP 비밀번호는 배포 시크릿으로 분리)
|
||||
- [x] `nodemailer` 패키지 설치 (`pnpm add nodemailer && pnpm add -D @types/nodemailer`)
|
||||
- [x] 메일 발송 모듈 구현 (`lib/mail.ts`)
|
||||
- [x] `createTransporter()` — 환경변수 기반 SMTP transport
|
||||
- [x] `sendMail()` — 공통 발송 함수 (`MAIL_SEND_ENABLED` 체크, From/BCC/ReplyTo 구성, 성공·실패 로그)
|
||||
- [x] `sendExtensionDoneEmail()` — 연장 완료 이메일 템플릿
|
||||
- [x] `sendUpgradeDoneEmail()` — 업그레이드 완료 이메일 템플릿 (체험→유료 / 유료→유료 내부 분기)
|
||||
- [x] `lib/extension-requests.ts` 수정
|
||||
- [x] `approveExtensionRequest` 반환값에 `maestroName`, `maestroEmail` 추가 (이미 `include: { maestro: true }` 있음 — 추가 쿼리 불필요)
|
||||
- [x] `lib/upgrade-requests.ts` 수정
|
||||
- [x] `getActionTarget`에 `include: { maestro: { select: { Name, Email } } }` 추가
|
||||
- [x] `approveUpgradeRequest` 반환값에 `maestroName`, `maestroEmail`, `registeredActivateStatus`, `registeredAccountType`, `requestedAccountType` 추가
|
||||
- [x] 승인 API에 이메일 자동 발송 연결
|
||||
- [x] `app/api/extension-requests/[id]/route.ts` — 승인 후 `void sendExtensionDoneEmail(...)` 호출
|
||||
- [x] `app/api/upgrade-requests/[id]/route.ts` — 승인 후 `void sendUpgradeDoneEmail(...)` 호출
|
||||
- [x] 메일 발송 실패 정책 적용: 실패 시 `logger.error`만 기록, 승인 결과(200)는 그대로 반환 (롤백 없음)
|
||||
- [x] 검증
|
||||
- [x] `MAIL_SEND_ENABLED=false`에서 승인 시 `send skipped` 로그 확인, 승인 결과 200 정상
|
||||
- [x] `MAIL_SEND_ENABLED=true`에서 연장 승인 후 이메일 제목/본문/수신자/BCC 확인
|
||||
- [ ] 업그레이드 승인 후 체험→유료 케이스: `0원 : 무료 체험 기간` 본문 확인
|
||||
- [ ] 업그레이드 승인 후 유료→유료 케이스: `{prev_price}원` 본문 확인
|
||||
- [x] SMTP 오류 시 승인 API 200 응답 + `error: send failed` 로그 확인
|
||||
|
||||
### Phase 12. 관리자 직접 연장/업그레이드 신청 등록
|
||||
|
||||
> 마에스트로 상세 화면에서 관리자가 직접 연장·업그레이드 신청을 DB에 등록할 수 있도록 한다. 메일 발송은 하지 않는다.
|
||||
|
||||
- [x] 연장 신청 등록 API 구현 (`POST /api/maestros/[id]/extension-requests`)
|
||||
- [x] 서버에서 `maestro.AccountType` 직접 조회 — 클라이언트 값 신뢰 안 함
|
||||
- [x] 유료 요금제 계정이 아닌 경우 400 반환 (체험 계정 차단)
|
||||
- [x] `maestro_extension` INSERT (`AccountType`, `RequestedDateTime`, `Status=1`)
|
||||
- [x] 업그레이드 신청 등록 API 구현 (`POST /api/maestros/[id]/upgrade-requests`)
|
||||
- [x] 트랜잭션 내에서 `maestro` SELECT → `maestro_upgrade` INSERT (원자성 보장)
|
||||
- [x] `requestedAccountType > maestro.AccountType` 검증 (다운그레이드 차단, 400 반환)
|
||||
- [x] `RegisteredActivateStatus`, `RegisteredAccountType` 서버 DB 조회값으로 저장
|
||||
- [x] 연장 신청 이력 클라이언트 컴포넌트 (`ExtensionRequestsSection.tsx`)
|
||||
- [x] 섹션 우상단 [연장] 버튼
|
||||
- [x] 체험 계정 (`AccountType` ∉ `PAID_ACCOUNT_TYPE_VALUES`) 시 버튼 비활성화
|
||||
- [x] 등록 성공 후 `router.refresh()`로 이력 테이블 즉시 갱신
|
||||
- [x] 업그레이드 신청 이력 클라이언트 컴포넌트 (`UpgradeRequestsSection.tsx`)
|
||||
- [x] 섹션 우상단 요금제 콤보박스 + [업그레이드] 버튼
|
||||
- [x] 현재 요금제 이하 옵션 `disabled`, 상위 요금제만 선택 가능
|
||||
- [x] 선택 후 등록 성공 시 콤보박스 초기화 + `router.refresh()`
|
||||
- [x] `PAID_ACCOUNT_TYPE_VALUES` 공용 상수로 추출 (`lib/constants.ts`)
|
||||
- [x] 기존 `lib/maestros.ts`의 로컬 정의 제거 및 import로 교체
|
||||
- [x] 두 신규 API route의 로컬 정의 제거 및 import로 교체
|
||||
|
||||
### Phase 13. 보안·정합성 수정
|
||||
|
||||
> AGENTS.md "Implemented phases" Phase 13 항목 참조.
|
||||
|
||||
- [x] 모든 항목 완료 (P0-1 ~ P2-8)
|
||||
|
||||
### Phase 14. 비밀번호 초기화 및 배포 스크립트
|
||||
|
||||
> 마에스트로 계정 비밀번호 초기화 기능을 추가한다.
|
||||
> 완료 날짜: 2026-07-05
|
||||
|
||||
- [x] 비밀번호 초기화 API 구현 (`POST /api/maestros/[id]/reset-password`)
|
||||
- [x] `resetMaestroPassword(maestroID)` 추가 (`lib/maestros.ts`)
|
||||
- [x] `$transaction` 내에서 `UPDATE maestro SET Password = PASSWORD('123456')` 실행
|
||||
- [x] `reset_maestro_password` 타입으로 `maestro_log` 기록
|
||||
- [x] `withApiHandler`로 인증·에러 처리 공통화
|
||||
- [x] 비밀번호 초기화 버튼 UI (`MaestroEditForm.tsx`)
|
||||
- [x] `[암호 초기화]` 버튼을 `[저장]` 버튼 우측에 추가 (`variant="outline"`, `KeyRound` 아이콘)
|
||||
- [x] `window.confirm`으로 `{마에스트로명} 계정의 비밀번호를 123456으로 초기화 하시겠습니까?` 확인
|
||||
- [x] 성공 시 기존 메시지 영역에 `암호 초기화 완료` 표시
|
||||
|
||||
### Phase 15. Production/Stage 배포 환경 안정화
|
||||
|
||||
> Production 첫 배포 과정에서 발견된 이슈들을 해결하고 NAS 배포 자동화 스크립트를 추가한다.
|
||||
> 완료 날짜: 2026-07-05
|
||||
|
||||
- [x] Production 배포 경로 생성 및 git pull 방식 배포 설정
|
||||
- Production: `/volume1/docker/service/jinaju/chocoadmin` + `docker-compose.yml`
|
||||
- Stage: 동일 경로, `-f docker-compose.stage.yml` 로 구분
|
||||
- [x] **포트 충돌 수정** (`docker-compose.yml`)
|
||||
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"` → `expose: "3000"` 으로 변경
|
||||
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
|
||||
- `services: chocoadmin:` → `services: chocoadmin-stage:` 으로 변경
|
||||
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
|
||||
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
|
||||
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
|
||||
- `auth.ts`: `cookies.sessionToken.name = "chocoadmin-${APP_ENV}.session-token"` 추가
|
||||
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
|
||||
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
|
||||
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
|
||||
- 인라인 `"use server"` 클로저는 빌드마다 새 ID 부여 → 재배포 후 `Failed to find Server Action`
|
||||
- 코드베이스 전체 인라인 서버 액션 검토 및 동일 패턴 수정 완료
|
||||
- [x] Production/Stage 동시 운영 정상 확인
|
||||
- [x] 배포 자동화 스크립트 추가 (`scripts/`)
|
||||
- [x] `scripts/deploy-stage.sh` — git pull → proxy-network 확인 → `docker compose -f docker-compose.stage.yml up -d --build --remove-orphans` → NPM reload
|
||||
- [x] `scripts/deploy-production.sh` — `yes` 입력 확인 후 동일 5단계 실행 (`docker-compose.yml` 사용)
|
||||
- [x] 각 스크립트는 NAS 해당 디렉토리에서 직접 실행
|
||||
|
||||
### Phase 16. 반응형 웹 디자인
|
||||
|
||||
> 모바일 화면(360~430px)에서도 관리자 패널을 정상 사용할 수 있도록 사이드바 내비게이션, 표 overflow, 페이지네이션 정렬을 개선한다.
|
||||
> 완료 날짜: 2026-07-07
|
||||
|
||||
- [x] 모바일 사이드바
|
||||
- [x] `app/(admin)/nav-config.ts` 생성 — navItems 배열을 `layout.tsx`와 `MobileNav.tsx`가 공유
|
||||
- [x] `app/(admin)/MobileNav.tsx` 생성 — 햄버거 버튼(`md:hidden`), 반투명 오버레이(탭하면 닫힘), 슬라이드인 패널(X 버튼), ESC 키 지원, pathname 변경 시 자동 닫힘, body 스크롤 잠금, CSS `translate-x` 트랜지션 애니메이션
|
||||
- [x] `app/(admin)/layout.tsx` 수정 — 헤더에 `<MobileNav />` 통합; 데스크탑 `<aside>`(`hidden md:block`)는 그대로 유지
|
||||
- [x] 표 overflow 수정 (`ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx`)
|
||||
- 원인: 두 섹션은 서버 props로 데이터를 받아 SSR 초기 렌더에 `min-w-[560px]` 표가 포함됨 → `section`에 `overflow` 미설정(`visible` 기본값)이어서 표가 섹션을 밀어냄 → `overflow-x-auto`가 확장된 너비를 그대로 가져가 스크롤 미발생. `StudentsSection`·`LogsSection`은 클라이언트 fetch 방식이라 SSR 초기 렌더에 표가 없어 동일 패턴으로도 정상 동작.
|
||||
- [x] `<section>`에 `overflow-hidden` 추가 — BFC 확립으로 섹션 너비를 뷰포트 기준으로 고정
|
||||
- [x] 섹션 헤더를 `flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between`으로 변경 — 모바일에서 세로 스택, 액션 버튼 접근성 확보
|
||||
- [x] 페이지네이션 오른쪽 정렬 — `<nav>`에 `self-end sm:self-auto` 추가 (모바일 `flex-col`에서 오른쪽 정렬, 데스크탑 `flex-row`에서 기존 동작 유지)
|
||||
- [x] `app/(admin)/extension-requests/page.tsx`
|
||||
- [x] `app/(admin)/upgrade-requests/page.tsx`
|
||||
- [x] `app/(admin)/maestros/page.tsx`
|
||||
- [x] `app/(admin)/maestros/[maestroId]/StudentsSection.tsx`
|
||||
- [x] `app/(admin)/maestros/[maestroId]/LogsSection.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP 범위
|
||||
|
||||
**1차 MVP (위 Phase 0~7):**
|
||||
- 관리자 로그인
|
||||
- 마에스트로 전체 목록 및 상세 정보 보기
|
||||
- 연장 신청 목록 / 승인 / 취소
|
||||
- 업그레이드 신청 목록 / 승인 / 거절
|
||||
- 관리자 액션 로그 기록
|
||||
- Docker 기반 NAS 배포
|
||||
|
||||
**MVP 이후 확장 후보:**
|
||||
- 학생 목록 상세 관리
|
||||
- 이메일 발송 이력/재발송 관리
|
||||
- 데이터 내보내기 (CSV)
|
||||
- 처리 통계 대시보드
|
||||
- 감사 로그 전용 화면
|
||||
- 관리자 계정 관리
|
||||
|
||||
---
|
||||
|
||||
## 11. 구현 전 확인이 필요한 사항
|
||||
|
||||
기존 `mouse-typing` 서비스 코드(`src/web/server/admin/`)에서 반드시 확인해야 할 비즈니스 규칙들이다.
|
||||
|
||||
1. 연장 승인 시 `AvailableActivateDateTime`을 정확히 얼마나 연장하는가? (1년? 계정 유형별 다름?)
|
||||
: 계정 유형별로 차이 없고, 1년 연장
|
||||
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
|
||||
: AccountType만 변경
|
||||
3. `admin.Password`는 어떤 방식으로 저장/검증되는가? (평문, MD5, bcrypt 등)
|
||||
: MariaDB의 Password() 함수를 이용해서 저장/검증됨
|
||||
4. `maestro_log`의 `Type` 컬럼에 어떤 값들이 사용되는가? (`Remark` 형식 포함)
|
||||
: 다음과 같은 값들이 사용됨
|
||||
* add_maestro
|
||||
* update_maestro
|
||||
* add_maestro
|
||||
* register_maestro
|
||||
* update_maestro_name
|
||||
* extension_maestro
|
||||
* upgrade_maestro
|
||||
* update_maestro_email
|
||||
5. 운영 MariaDB가 외부 IP 접속을 허용할 수 있는 네트워크 구조인가?
|
||||
: 외부 IP 접속을 허용하고 있다
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-05-26*
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export type ApiHandlerContext<P> = {
|
||||
params: Promise<P>;
|
||||
t0: number;
|
||||
};
|
||||
|
||||
export function withApiHandler<P = Record<string, never>>(
|
||||
ctx: string,
|
||||
handler: (req: Request, ctx: ApiHandlerContext<P>) => Promise<NextResponse>
|
||||
) {
|
||||
return async (
|
||||
request: Request,
|
||||
routeContext?: { params: Promise<P> }
|
||||
): Promise<NextResponse> => {
|
||||
const t0 = Date.now();
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
logger.warn(ctx, "unauthorized");
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const handlerCtx: ApiHandlerContext<P> = {
|
||||
params: routeContext?.params ?? (Promise.resolve({}) as Promise<P>),
|
||||
t0,
|
||||
};
|
||||
|
||||
try {
|
||||
return await handler(request, handlerCtx);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
const level = error.statusCode >= 500 ? "error" : "warn";
|
||||
logger[level](ctx, error.message, {
|
||||
status: error.statusCode,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
logger.error(ctx, "unexpected error", {
|
||||
error: String(error),
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export const ACCOUNT_TYPES = {
|
||||
BASIC_20: 1,
|
||||
STANDARD_50: 2,
|
||||
PRO_100: 3,
|
||||
SCHOOL_500: 4,
|
||||
SCHOOL_1000: 5,
|
||||
STUDENT_TRIAL: 100,
|
||||
MAESTRO_TRIAL: 101,
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_LABELS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: "20명 (1만원)",
|
||||
[ACCOUNT_TYPES.STANDARD_50]: "50명 (2만원)",
|
||||
[ACCOUNT_TYPES.PRO_100]: "100명 (3만원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (4만원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (5만원)",
|
||||
[ACCOUNT_TYPES.STUDENT_TRIAL]: "학생체험",
|
||||
[ACCOUNT_TYPES.MAESTRO_TRIAL]: "마에체험",
|
||||
} as const;
|
||||
|
||||
export const TRIAL_ACCOUNT_TYPE_LABELS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: "20명 (0원)",
|
||||
[ACCOUNT_TYPES.STANDARD_50]: "50명 (0원)",
|
||||
[ACCOUNT_TYPES.PRO_100]: "100명 (0원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (0원)",
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (0원)",
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_PLAYER_COUNTS = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: 20,
|
||||
[ACCOUNT_TYPES.STANDARD_50]: 50,
|
||||
[ACCOUNT_TYPES.PRO_100]: 100,
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: 500,
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: 1000,
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_TYPE_PRICES = {
|
||||
[ACCOUNT_TYPES.BASIC_20]: 10000,
|
||||
[ACCOUNT_TYPES.STANDARD_50]: 20000,
|
||||
[ACCOUNT_TYPES.PRO_100]: 30000,
|
||||
[ACCOUNT_TYPES.SCHOOL_500]: 40000,
|
||||
[ACCOUNT_TYPES.SCHOOL_1000]: 50000,
|
||||
} as const;
|
||||
|
||||
export const ACTIVATE_STATUSES = {
|
||||
PENDING: 0,
|
||||
TRIAL: 1,
|
||||
ACTIVE: 2,
|
||||
CANCELED: 100,
|
||||
} as const;
|
||||
|
||||
export const ACTIVATE_STATUS_LABELS = {
|
||||
[ACTIVATE_STATUSES.PENDING]: "미승인",
|
||||
[ACTIVATE_STATUSES.TRIAL]: "체험",
|
||||
[ACTIVATE_STATUSES.ACTIVE]: "활성화",
|
||||
[ACTIVATE_STATUSES.CANCELED]: "등록 취소",
|
||||
} as const;
|
||||
|
||||
export const REQUEST_STATUSES = {
|
||||
REQUESTED: 1,
|
||||
CLOSED: 2,
|
||||
APPLIED: 3,
|
||||
} as const;
|
||||
|
||||
export const REQUEST_STATUS_LABELS = {
|
||||
[REQUEST_STATUSES.REQUESTED]: "요청",
|
||||
[REQUEST_STATUSES.CLOSED]: "취소",
|
||||
[REQUEST_STATUSES.APPLIED]: "적용 완료",
|
||||
} as const;
|
||||
|
||||
export const PAID_ACCOUNT_TYPE_VALUES = [
|
||||
ACCOUNT_TYPES.BASIC_20,
|
||||
ACCOUNT_TYPES.STANDARD_50,
|
||||
ACCOUNT_TYPES.PRO_100,
|
||||
ACCOUNT_TYPES.SCHOOL_500,
|
||||
ACCOUNT_TYPES.SCHOOL_1000,
|
||||
] as const;
|
||||
|
||||
export type AccountType = (typeof ACCOUNT_TYPES)[keyof typeof ACCOUNT_TYPES];
|
||||
export type PaidAccountType =
|
||||
| typeof ACCOUNT_TYPES.BASIC_20
|
||||
| typeof ACCOUNT_TYPES.STANDARD_50
|
||||
| typeof ACCOUNT_TYPES.PRO_100
|
||||
| typeof ACCOUNT_TYPES.SCHOOL_500
|
||||
| typeof ACCOUNT_TYPES.SCHOOL_1000;
|
||||
export type ActivateStatus =
|
||||
(typeof ACTIVATE_STATUSES)[keyof typeof ACTIVATE_STATUSES];
|
||||
export type RequestStatus =
|
||||
(typeof REQUEST_STATUSES)[keyof typeof REQUEST_STATUSES];
|
||||
@@ -0,0 +1,266 @@
|
||||
import { writeSync } from "fs";
|
||||
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { format } from "sql-formatter";
|
||||
|
||||
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
|
||||
import { logger, timestamp } from "@/lib/logger";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
|
||||
|
||||
// Docker 파이프 환경에서 stdout fd는 non-blocking이라 writeSync가 부분 쓰기를
|
||||
// 하거나 EAGAIN을 던질 수 있다. 전부 쓸 때까지 반복해 로그 유실을 막는다.
|
||||
function writeAll(fd: number, text: string): void {
|
||||
const buf = Buffer.from(text, "utf8");
|
||||
let offset = 0;
|
||||
while (offset < buf.length) {
|
||||
try {
|
||||
offset += writeSync(fd, buf, offset, buf.length - offset);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EAGAIN") continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SQL 로깅 ($on) ────────────────────────────────────────────────
|
||||
|
||||
function inlineParams(query: string, rawParams: string): string {
|
||||
try {
|
||||
const params = JSON.parse(rawParams) as unknown[];
|
||||
let idx = 0;
|
||||
return query.replace(/\?/g, () => {
|
||||
const val = params[idx++];
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
||||
if (typeof val === "boolean") return val ? "1" : "0";
|
||||
return String(val);
|
||||
});
|
||||
} catch {
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
function onQuery(e: Prisma.QueryEvent): void {
|
||||
const isSlow = e.duration >= SLOW_QUERY_MS;
|
||||
const isSensitive = e.query.includes("PASSWORD(");
|
||||
const sqlWithParams = isSensitive ? e.query : inlineParams(e.query, e.params);
|
||||
|
||||
if (isSlow) {
|
||||
const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 });
|
||||
const ts = timestamp();
|
||||
const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n");
|
||||
writeAll(2, `[${ts}] [WARN ] [db:query] ${e.duration}ms [SLOW]\n${indented}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (logger.isEnabled("debug")) {
|
||||
const formatted = format(sqlWithParams, { language: "mysql", tabWidth: 2 });
|
||||
const ts = timestamp();
|
||||
const indented = formatted.split("\n").map((l) => ` ${l}`).join("\n");
|
||||
writeAll(1, `[${ts}] [DEBUG] [db:query] ${e.duration}ms\n${indented}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (logger.isEnabled("info")) {
|
||||
const sql = sqlWithParams.replace(/\s+/g, " ").trim();
|
||||
const ts = timestamp();
|
||||
writeAll(1, `[${ts}] [INFO ] [db:query] ${e.duration}ms ${sql}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
|
||||
|
||||
const COL_MAX_WIDTH = 40;
|
||||
|
||||
const SENSITIVE_KEYS = new Set(["Password", "password"]);
|
||||
|
||||
function safeValue(key: string, value: unknown): string {
|
||||
return SENSITIVE_KEYS.has(key) ? "***" : formatValue(value);
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
|
||||
function isWide(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
|
||||
(cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
||||
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
||||
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
|
||||
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
|
||||
cp >= 0x20000 // CJK Extension B+
|
||||
);
|
||||
}
|
||||
|
||||
function displayWidth(s: string): number {
|
||||
let w = 0;
|
||||
for (const char of s) {
|
||||
w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
function truncateStr(s: string, maxWidth: number): string {
|
||||
if (displayWidth(s) <= maxWidth) return s;
|
||||
let w = 0;
|
||||
let result = "";
|
||||
for (const char of s) {
|
||||
const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
|
||||
w += cw;
|
||||
result += char;
|
||||
}
|
||||
return result + "…";
|
||||
}
|
||||
|
||||
function padEnd(s: string, width: number): string {
|
||||
const truncated = truncateStr(s, width);
|
||||
return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.min(
|
||||
COL_MAX_WIDTH,
|
||||
Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
|
||||
)
|
||||
);
|
||||
|
||||
const rule = (l: string, m: string, r: string, s: string) =>
|
||||
l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
|
||||
|
||||
const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
|
||||
const dRows = rows.map(
|
||||
(r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
|
||||
);
|
||||
|
||||
return [
|
||||
rule("┌", "┬", "┐", "─"),
|
||||
hRow,
|
||||
rule("├", "┼", "┤", "─"),
|
||||
...dRows,
|
||||
rule("└", "┴", "┘", "─"),
|
||||
]
|
||||
.map((l) => ` ${l}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildResultLog(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): string {
|
||||
const label = model ? `${model}.${operation}` : operation;
|
||||
|
||||
if (typeof result === "number") {
|
||||
return `${label} → ${result}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object" && "count" in result) {
|
||||
return `${label} → count: ${(result as { count: number }).count}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const total = result.length;
|
||||
if (total === 0) return `${label} → 0 rows`;
|
||||
|
||||
const preview = result.slice(0, 5);
|
||||
const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
|
||||
const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
|
||||
|
||||
if (typeof preview[0] === "object" && preview[0] !== null) {
|
||||
const objRows = preview as Record<string, unknown>[];
|
||||
const headers = Object.keys(objRows[0]);
|
||||
const rows = objRows.map((row) => headers.map((h) => safeValue(h, row[h])));
|
||||
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
const rows = preview.map((v) => [formatValue(v)]);
|
||||
return `${label} → ${countLabel}\n${renderTable(["value"], rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object") {
|
||||
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
safeValue(k, v),
|
||||
]);
|
||||
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
||||
}
|
||||
|
||||
return `${label} → ${String(result)}`;
|
||||
}
|
||||
|
||||
function logResult(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): void {
|
||||
if (!logger.isEnabled("debug")) return;
|
||||
const ts = timestamp();
|
||||
const body = buildResultLog(model, operation, result);
|
||||
writeAll(1, `[${ts}] [DEBUG] [db:result] ${body}\n`);
|
||||
}
|
||||
|
||||
// ── PrismaClient 생성 ─────────────────────────────────────────────
|
||||
|
||||
function createPrismaClient() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is required to initialize Prisma Client.");
|
||||
}
|
||||
|
||||
const adapter = new PrismaMariaDb(databaseUrl);
|
||||
const baseClient = new PrismaClient({
|
||||
adapter,
|
||||
log: [{ emit: "event", level: "query" }],
|
||||
});
|
||||
|
||||
baseClient.$on("query", onQuery);
|
||||
|
||||
const extendedClient = baseClient.$extends({
|
||||
query: {
|
||||
$allOperations: async ({ model, operation, args, query }) => {
|
||||
try {
|
||||
const result = await query(args);
|
||||
logResult(model, operation, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const label = model ? `${model}.${operation}` : operation;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const ts = timestamp();
|
||||
writeAll(2, `[${ts}] [ERROR] [db:error] ${label} failed: ${msg}\n`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
|
||||
return extendedClient as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = db;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
calculateExtendedAvailableDate,
|
||||
getAccountTypeLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
|
||||
const extensionRequestSearchSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => pageSizeOptions.includes(value as PageSize))
|
||||
.catch(10),
|
||||
q: z.string().trim().catch(""),
|
||||
status: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.default(REQUEST_STATUSES.REQUESTED)
|
||||
.catch(REQUEST_STATUSES.REQUESTED),
|
||||
});
|
||||
|
||||
type PageSize = (typeof pageSizeOptions)[number];
|
||||
export type RawSearchParams =
|
||||
| URLSearchParams
|
||||
| Record<string, string | string[] | undefined>;
|
||||
export type ExtensionRequestSearchParams = z.infer<
|
||||
typeof extensionRequestSearchSchema
|
||||
>;
|
||||
|
||||
export type ExtensionRequestListItem = {
|
||||
maestroExtensionID: number;
|
||||
maestroID: number;
|
||||
maestroName: string;
|
||||
requestedAccountType: number;
|
||||
currentAccountType: number;
|
||||
activateStatus: number;
|
||||
requestedDateTime: string;
|
||||
availableActivateDateTime: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export type ExtensionRequestListResult = {
|
||||
items: ExtensionRequestListItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
status: number | "all";
|
||||
};
|
||||
|
||||
export async function getExtensionRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<ExtensionRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseExtensionRequestSearchParams(rawSearchParams);
|
||||
const where = buildExtensionRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const [totalCount, requests] = await Promise.all([
|
||||
db.maestro_extension.count({ where }),
|
||||
db.maestro_extension.findMany({
|
||||
where,
|
||||
orderBy: { MaestroExtensionID: "desc" },
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
MaestroID: true,
|
||||
AccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
maestro: {
|
||||
select: {
|
||||
Name: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
AvailableActivateDateTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("extension-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toExtensionRequestListItem),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
|
||||
q: params.q,
|
||||
status: params.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function approveExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
}> {
|
||||
return db.$transaction(async (tx) => {
|
||||
// Atomically claim the request — prevents concurrent double-approval
|
||||
const claimed = await tx.maestro_extension.updateMany({
|
||||
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
|
||||
data: { Status: REQUEST_STATUSES.APPLIED },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
const exists = await tx.maestro_extension.count({
|
||||
where: { MaestroExtensionID: maestroExtensionID },
|
||||
});
|
||||
throw new ApiError(
|
||||
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
|
||||
exists ? 409 : 404
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_extension.findUnique({
|
||||
where: { MaestroExtensionID: maestroExtensionID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
AccountType: true,
|
||||
maestro: {
|
||||
select: {
|
||||
Name: true,
|
||||
Email: true,
|
||||
AvailableActivateDateTime: true,
|
||||
ActivateStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
||||
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
|
||||
}
|
||||
|
||||
const availableActivateDateTime = calculateExtendedAvailableDate(
|
||||
request!.maestro.AvailableActivateDateTime
|
||||
);
|
||||
|
||||
await tx.maestro.update({
|
||||
where: { MaestroID: request!.MaestroID },
|
||||
data: {
|
||||
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
||||
AvailableActivateDateTime: availableActivateDateTime,
|
||||
},
|
||||
});
|
||||
await tx.maestro_extension.updateMany({
|
||||
where: {
|
||||
MaestroID: request!.MaestroID,
|
||||
Status: REQUEST_STATUSES.REQUESTED,
|
||||
},
|
||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||
});
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "extension_maestro",
|
||||
MaestroID: request!.MaestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildExtensionLogRemark(
|
||||
request!.maestro.Name,
|
||||
request!.AccountType
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request!.maestro.Name.trim(),
|
||||
maestroEmail: request!.maestro.Email.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createExtensionRequest(
|
||||
maestroID: number
|
||||
): Promise<{ maestroExtensionID: number; requestedDateTime: string; status: number }> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const maestro = await tx.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: { AccountType: true, ActivateStatus: true, Name: true },
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
||||
}
|
||||
|
||||
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) {
|
||||
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400);
|
||||
}
|
||||
|
||||
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
||||
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_extension.create({
|
||||
data: {
|
||||
MaestroID: maestroID,
|
||||
AccountType: maestro.AccountType,
|
||||
RequestedDateTime: new Date(),
|
||||
Status: REQUEST_STATUSES.REQUESTED,
|
||||
},
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "request_extension_maestro",
|
||||
MaestroID: maestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildExtensionLogRemark(maestro.Name, maestro.AccountType),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
maestroExtensionID: request.MaestroExtensionID,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelExtensionRequest(
|
||||
maestroExtensionID: number
|
||||
): Promise<void> {
|
||||
await db.$transaction(async (tx) => {
|
||||
const claimed = await tx.maestro_extension.updateMany({
|
||||
where: { MaestroExtensionID: maestroExtensionID, Status: REQUEST_STATUSES.REQUESTED },
|
||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
const exists = await tx.maestro_extension.count({
|
||||
where: { MaestroExtensionID: maestroExtensionID },
|
||||
});
|
||||
throw new ApiError(
|
||||
exists ? "이미 처리된 연장 신청입니다." : "연장 신청을 찾을 수 없습니다.",
|
||||
exists ? 409 : 404
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_extension.findUnique({
|
||||
where: { MaestroExtensionID: maestroExtensionID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
AccountType: true,
|
||||
maestro: { select: { Name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "cancel_extension_maestro",
|
||||
MaestroID: request!.MaestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildExtensionLogRemark(
|
||||
request!.maestro.Name,
|
||||
request!.AccountType
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExtensionRequestSearchParams(
|
||||
rawSearchParams: RawSearchParams
|
||||
): ExtensionRequestSearchParams {
|
||||
return extensionRequestSearchSchema.parse(
|
||||
searchParamsToRecord(rawSearchParams)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function buildExtensionRequestWhere(
|
||||
params: ExtensionRequestSearchParams
|
||||
): Prisma.maestro_extensionWhereInput {
|
||||
const conditions: Prisma.maestro_extensionWhereInput[] = [];
|
||||
|
||||
if (params.q) {
|
||||
const keywordConditions: Prisma.maestro_extensionWhereInput[] = [
|
||||
{ maestro: { Name: { contains: params.q } } },
|
||||
];
|
||||
const numericQuery = Number(params.q);
|
||||
|
||||
if (Number.isInteger(numericQuery) && numericQuery > 0) {
|
||||
keywordConditions.unshift(
|
||||
{ MaestroExtensionID: numericQuery },
|
||||
{ MaestroID: numericQuery }
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push({ OR: keywordConditions });
|
||||
}
|
||||
|
||||
if (typeof params.status === "number" && Number.isInteger(params.status)) {
|
||||
conditions.push({ Status: params.status });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : {};
|
||||
}
|
||||
|
||||
function searchParamsToRecord(
|
||||
rawSearchParams: RawSearchParams
|
||||
): Record<string, string | undefined> {
|
||||
if (rawSearchParams instanceof URLSearchParams) {
|
||||
return Object.fromEntries(rawSearchParams.entries());
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawSearchParams).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value[0] : value,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function toExtensionRequestListItem(request: {
|
||||
MaestroExtensionID: number;
|
||||
MaestroID: number;
|
||||
AccountType: number;
|
||||
RequestedDateTime: Date;
|
||||
Status: number;
|
||||
maestro: {
|
||||
Name: string;
|
||||
AccountType: number;
|
||||
ActivateStatus: number;
|
||||
AvailableActivateDateTime: Date;
|
||||
};
|
||||
}): ExtensionRequestListItem {
|
||||
return {
|
||||
maestroExtensionID: request.MaestroExtensionID,
|
||||
maestroID: request.MaestroID,
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
requestedAccountType: request.AccountType,
|
||||
currentAccountType: request.maestro.AccountType,
|
||||
activateStatus: request.maestro.ActivateStatus,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
availableActivateDateTime:
|
||||
request.maestro.AvailableActivateDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExtensionLogRemark(maestroName: string, accountType: number) {
|
||||
return `${maestroName.trim()}(${getAccountTypeLabel(accountType)})`.slice(
|
||||
0,
|
||||
100
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const PRIORITY: Record<Level, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function getMinLevel(): Level {
|
||||
const env = process.env.LOG_LEVEL as Level | undefined;
|
||||
if (env && env in PRIORITY) return env;
|
||||
const appEnv = process.env.APP_ENV;
|
||||
return appEnv === "production" ? "info" : "debug";
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return PRIORITY[level] >= PRIORITY[getMinLevel()];
|
||||
}
|
||||
|
||||
// TZ 환경변수 기준 로컬 시간 (toISOString은 항상 UTC라 사용하지 않음)
|
||||
export function timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||
const offMin = -d.getTimezoneOffset();
|
||||
const sign = offMin >= 0 ? "+" : "-";
|
||||
const abs = Math.abs(offMin);
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
||||
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
||||
`.${pad(d.getMilliseconds(), 3)}` +
|
||||
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
|
||||
);
|
||||
}
|
||||
|
||||
function write(
|
||||
level: Level,
|
||||
ctx: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!shouldLog(level)) return;
|
||||
const ts = timestamp();
|
||||
const tag = level.toUpperCase().padEnd(5);
|
||||
const line = data
|
||||
? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}`
|
||||
: `[${ts}] [${tag}] [${ctx}] ${message}`;
|
||||
if (level === "warn" || level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("debug", ctx, message, data),
|
||||
info: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("info", ctx, message, data),
|
||||
warn: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("warn", ctx, message, data),
|
||||
error: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("error", ctx, message, data),
|
||||
isEnabled: (level: Level): boolean => shouldLog(level),
|
||||
};
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
const sortOptions = [
|
||||
"id-desc",
|
||||
"id-asc",
|
||||
"joinedAt-desc",
|
||||
"joinedAt-asc",
|
||||
"availableAt-desc",
|
||||
"availableAt-asc",
|
||||
] as const;
|
||||
|
||||
const maestroListSearchSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => pageSizeOptions.includes(value as PageSize))
|
||||
.catch(10),
|
||||
q: z.string().trim().catch(""),
|
||||
activateStatus: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
accountType: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
sort: z.enum(sortOptions).catch("id-desc"),
|
||||
});
|
||||
|
||||
type PageSize = (typeof pageSizeOptions)[number];
|
||||
export type MaestroSort = (typeof sortOptions)[number];
|
||||
export type MaestroListSearchParams = z.infer<typeof maestroListSearchSchema>;
|
||||
export type RawSearchParams =
|
||||
| URLSearchParams
|
||||
| Record<string, string | string[] | undefined>;
|
||||
|
||||
export type MaestroListItem = {
|
||||
maestroID: number;
|
||||
name: string;
|
||||
email: string;
|
||||
accountType: number;
|
||||
activateStatus: number;
|
||||
availableActivateDateTime: string;
|
||||
acceptClausesDateTime: string;
|
||||
playerCount: number;
|
||||
};
|
||||
|
||||
export type MaestroListResult = {
|
||||
items: MaestroListItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
activateStatus?: number | "all";
|
||||
accountType?: number | "all";
|
||||
sort: MaestroSort;
|
||||
};
|
||||
|
||||
export type MaestroDetail = {
|
||||
maestro: MaestroListItem & {
|
||||
allowEditEnterCode: number;
|
||||
maestroTestID: number | null;
|
||||
};
|
||||
counts: {
|
||||
players: number;
|
||||
extensionRequests: number;
|
||||
upgradeRequests: number;
|
||||
};
|
||||
players: Array<{
|
||||
playerID: number;
|
||||
name: string;
|
||||
enterCode: string;
|
||||
accountType: number | null;
|
||||
}>;
|
||||
extensionRequests: Array<{
|
||||
maestroExtensionID: number;
|
||||
accountType: number;
|
||||
requestedDateTime: string;
|
||||
status: number;
|
||||
}>;
|
||||
upgradeRequests: Array<{
|
||||
maestroUpgradeID: number;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
requestedDateTime: string;
|
||||
status: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const activateStatusValues = [
|
||||
ACTIVATE_STATUSES.PENDING,
|
||||
ACTIVATE_STATUSES.TRIAL,
|
||||
ACTIVATE_STATUSES.ACTIVE,
|
||||
ACTIVATE_STATUSES.CANCELED,
|
||||
] as const;
|
||||
|
||||
export const updateMaestroSchema = z.object({
|
||||
name: z.string().trim().min(1).max(50).optional(),
|
||||
email: z.string().trim().email().max(50).optional(),
|
||||
activateStatus: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => (activateStatusValues as readonly number[]).includes(v))
|
||||
.optional(),
|
||||
accountType: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(v))
|
||||
.optional(),
|
||||
availableActivateDateTime: z.string().min(1).optional(),
|
||||
allowEditEnterCode: z.coerce.number().int().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
export type UpdateMaestroPayload = z.infer<typeof updateMaestroSchema>;
|
||||
|
||||
export async function updateMaestro(
|
||||
maestroID: number,
|
||||
payload: UpdateMaestroPayload
|
||||
): Promise<{ changed: boolean } | null> {
|
||||
const t0 = Date.now();
|
||||
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: {
|
||||
Name: true,
|
||||
Email: true,
|
||||
ActivateStatus: true,
|
||||
AccountType: true,
|
||||
AvailableActivateDateTime: true,
|
||||
AllowEditEnterCode: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!maestro) return null;
|
||||
|
||||
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
|
||||
const conflict = await db.maestro.findFirst({
|
||||
where: { Name: payload.name, NOT: { MaestroID: maestroID } },
|
||||
select: { MaestroID: true },
|
||||
});
|
||||
if (conflict) {
|
||||
throw new ApiError("이미 사용 중인 이름입니다.", 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: {
|
||||
Name?: string;
|
||||
Email?: string;
|
||||
ActivateStatus?: number;
|
||||
AccountType?: number;
|
||||
AvailableActivateDateTime?: Date;
|
||||
AllowEditEnterCode?: number;
|
||||
} = {};
|
||||
const logs: Array<{ type: string; remark: string }> = [];
|
||||
|
||||
if (payload.name !== undefined && payload.name !== maestro.Name.trim()) {
|
||||
updates.Name = payload.name;
|
||||
logs.push({
|
||||
type: "update_maestro_name",
|
||||
remark: `${maestro.Name.trim()} -> ${payload.name}`.slice(0, 100),
|
||||
});
|
||||
}
|
||||
if (payload.email !== undefined && payload.email !== maestro.Email.trim()) {
|
||||
updates.Email = payload.email;
|
||||
logs.push({
|
||||
type: "update_maestro_email",
|
||||
remark: `${maestro.Email.trim()} -> ${payload.email}`.slice(0, 100),
|
||||
});
|
||||
}
|
||||
if (
|
||||
payload.activateStatus !== undefined &&
|
||||
payload.activateStatus !== maestro.ActivateStatus
|
||||
) {
|
||||
updates.ActivateStatus = payload.activateStatus;
|
||||
logs.push({
|
||||
type: "update_maestro_status",
|
||||
remark: `${maestro.ActivateStatus} -> ${payload.activateStatus}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
payload.accountType !== undefined &&
|
||||
payload.accountType !== maestro.AccountType
|
||||
) {
|
||||
updates.AccountType = payload.accountType;
|
||||
logs.push({
|
||||
type: "update_maestro_account_type",
|
||||
remark: `${maestro.AccountType} -> ${payload.accountType}`,
|
||||
});
|
||||
}
|
||||
if (payload.availableActivateDateTime !== undefined) {
|
||||
const newDate = new Date(payload.availableActivateDateTime);
|
||||
if (isNaN(newDate.getTime())) {
|
||||
throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
|
||||
}
|
||||
const prevMin = Math.floor(
|
||||
maestro.AvailableActivateDateTime.getTime() / 60000
|
||||
);
|
||||
const newMin = Math.floor(newDate.getTime() / 60000);
|
||||
if (prevMin !== newMin) {
|
||||
updates.AvailableActivateDateTime = newDate;
|
||||
const prevStr = formatDate(maestro.AvailableActivateDateTime);
|
||||
const newStr = formatDate(newDate);
|
||||
logs.push({
|
||||
type: "update_maestro_available_date",
|
||||
remark: `${prevStr} -> ${newStr}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
payload.allowEditEnterCode !== undefined &&
|
||||
payload.allowEditEnterCode !== maestro.AllowEditEnterCode
|
||||
) {
|
||||
updates.AllowEditEnterCode = payload.allowEditEnterCode;
|
||||
logs.push({
|
||||
type: "update_maestro_allow_enter_code",
|
||||
remark: `${maestro.AllowEditEnterCode} -> ${payload.allowEditEnterCode}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { changed: false };
|
||||
}
|
||||
|
||||
await db.$transaction([
|
||||
db.maestro.update({ where: { MaestroID: maestroID }, data: updates }),
|
||||
...logs.map((log) =>
|
||||
db.maestro_log.create({
|
||||
data: {
|
||||
Type: log.type,
|
||||
MaestroID: maestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: log.remark,
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
logger.info("maestros/[id]", "updated", {
|
||||
id: maestroID,
|
||||
fields: Object.keys(updates),
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return { changed: true };
|
||||
}
|
||||
|
||||
export async function resetMaestroPassword(
|
||||
maestroID: number
|
||||
): Promise<boolean> {
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: { MaestroID: true },
|
||||
});
|
||||
|
||||
if (!maestro) return false;
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`UPDATE maestro SET Password = PASSWORD('123456') WHERE MaestroID = ${maestroID}`;
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "reset_maestro_password",
|
||||
MaestroID: maestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: "admin reset to 123456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const logPageSizeOptions = [10, 20, 50] as const;
|
||||
type LogPageSize = (typeof logPageSizeOptions)[number];
|
||||
|
||||
const logSearchSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => logPageSizeOptions.includes(v as LogPageSize))
|
||||
.catch(10),
|
||||
});
|
||||
|
||||
export type MaestroLogsResult = {
|
||||
items: Array<{
|
||||
maestroLogID: number;
|
||||
type: string;
|
||||
logDateTime: string;
|
||||
remark: string;
|
||||
}>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export async function getMaestroLogs(
|
||||
maestroID: number,
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroLogsResult | null> {
|
||||
const t0 = Date.now();
|
||||
|
||||
const maestroExists = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: { MaestroID: true },
|
||||
});
|
||||
|
||||
if (!maestroExists) return null;
|
||||
|
||||
const params = logSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const [totalCount, logs] = await Promise.all([
|
||||
db.maestro_log.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_log.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroLogID: "desc" },
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
MaestroLogID: true,
|
||||
Type: true,
|
||||
LogDateTime: true,
|
||||
Remark: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
logger.debug("maestros/[id]/logs", "fetched", {
|
||||
id: maestroID,
|
||||
page: params.page,
|
||||
totalCount,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: logs.map((log) => ({
|
||||
maestroLogID: log.MaestroLogID,
|
||||
type: log.Type.trim(),
|
||||
logDateTime: log.LogDateTime.toISOString(),
|
||||
remark: log.Remark.trim(),
|
||||
})),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
const studentPageSizeOptions = [10, 20, 50] as const;
|
||||
type StudentPageSize = (typeof studentPageSizeOptions)[number];
|
||||
|
||||
const studentSearchSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((v) => studentPageSizeOptions.includes(v as StudentPageSize))
|
||||
.catch(20),
|
||||
q: z.string().trim().catch(""),
|
||||
});
|
||||
|
||||
export type MaestroStudentsResult = {
|
||||
items: Array<{
|
||||
playerID: number;
|
||||
name: string;
|
||||
enterCode: string;
|
||||
accountType: number | null;
|
||||
}>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
};
|
||||
|
||||
export async function getMaestroStudents(
|
||||
maestroID: number,
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroStudentsResult | null> {
|
||||
const t0 = Date.now();
|
||||
|
||||
const maestroExists = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: { MaestroID: true },
|
||||
});
|
||||
|
||||
if (!maestroExists) return null;
|
||||
|
||||
const params = studentSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const where: Prisma.playerWhereInput = {
|
||||
MaestroID: maestroID,
|
||||
...(params.q ? { Name: { contains: params.q } } : {}),
|
||||
};
|
||||
|
||||
const [totalCount, players] = await Promise.all([
|
||||
db.player.count({ where }),
|
||||
db.player.findMany({
|
||||
where,
|
||||
orderBy: { PlayerID: "desc" },
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
PlayerID: true,
|
||||
Name: true,
|
||||
EnterCode: true,
|
||||
AccountType: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
logger.debug("maestros/[id]/students", "fetched", {
|
||||
id: maestroID,
|
||||
q: params.q,
|
||||
page: params.page,
|
||||
totalCount,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: players.map((p) => ({
|
||||
playerID: p.PlayerID,
|
||||
name: p.Name.trim(),
|
||||
enterCode: p.EnterCode.trim(),
|
||||
accountType: p.AccountType,
|
||||
})),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages,
|
||||
q: params.q,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMaestros(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseMaestroListSearchParams(rawSearchParams);
|
||||
const where = buildMaestroWhere(params);
|
||||
const orderBy = buildMaestroOrderBy(params.sort);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const [totalCount, maestros] = await Promise.all([
|
||||
db.maestro.count({ where }),
|
||||
db.maestro.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
Email: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
AvailableActivateDateTime: true,
|
||||
AcceptClausesDateTime: true,
|
||||
PlayerCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
logger.info("maestros", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: maestros.map(toMaestroListItem),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages,
|
||||
q: params.q,
|
||||
activateStatus: params.activateStatus,
|
||||
accountType: params.accountType,
|
||||
sort: params.sort,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMaestroDetail(
|
||||
maestroID: number
|
||||
): Promise<MaestroDetail | null> {
|
||||
const t0 = Date.now();
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
Email: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
AvailableActivateDateTime: true,
|
||||
AcceptClausesDateTime: true,
|
||||
PlayerCount: true,
|
||||
AllowEditEnterCode: true,
|
||||
MaestroTestID: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
logger.warn("maestros/[id]", "not found", { id: maestroID });
|
||||
return null;
|
||||
}
|
||||
|
||||
const [
|
||||
playersCount,
|
||||
extensionRequestsCount,
|
||||
upgradeRequestsCount,
|
||||
players,
|
||||
extensionRequests,
|
||||
upgradeRequests,
|
||||
] = await Promise.all([
|
||||
db.player.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
|
||||
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
|
||||
db.player.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { PlayerID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
PlayerID: true,
|
||||
Name: true,
|
||||
EnterCode: true,
|
||||
AccountType: true,
|
||||
},
|
||||
}),
|
||||
db.maestro_extension.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroExtensionID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
AccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
}),
|
||||
db.maestro_upgrade.findMany({
|
||||
where: { MaestroID: maestroID },
|
||||
orderBy: { MaestroUpgradeID: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
RegisteredActivateStatus: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("maestros/[id]", "fetched", {
|
||||
id: maestroID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
maestro: {
|
||||
...toMaestroListItem(maestro),
|
||||
allowEditEnterCode: maestro.AllowEditEnterCode,
|
||||
maestroTestID: maestro.MaestroTestID,
|
||||
},
|
||||
counts: {
|
||||
players: playersCount,
|
||||
extensionRequests: extensionRequestsCount,
|
||||
upgradeRequests: upgradeRequestsCount,
|
||||
},
|
||||
players: players.map((player) => ({
|
||||
playerID: player.PlayerID,
|
||||
name: player.Name.trim(),
|
||||
enterCode: player.EnterCode.trim(),
|
||||
accountType: player.AccountType,
|
||||
})),
|
||||
extensionRequests: extensionRequests.map((request) => ({
|
||||
maestroExtensionID: request.MaestroExtensionID,
|
||||
accountType: request.AccountType,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
})),
|
||||
upgradeRequests: upgradeRequests.map((request) => ({
|
||||
maestroUpgradeID: request.MaestroUpgradeID,
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMaestroListSearchParams(
|
||||
rawSearchParams: RawSearchParams
|
||||
): MaestroListSearchParams {
|
||||
return maestroListSearchSchema.parse(searchParamsToRecord(rawSearchParams));
|
||||
}
|
||||
|
||||
function buildMaestroWhere(
|
||||
params: MaestroListSearchParams
|
||||
): Prisma.maestroWhereInput {
|
||||
const conditions: Prisma.maestroWhereInput[] = [];
|
||||
|
||||
if (params.q) {
|
||||
const keywordConditions: Prisma.maestroWhereInput[] = [
|
||||
{ Name: { contains: params.q } },
|
||||
{ Email: { contains: params.q } },
|
||||
];
|
||||
const numericQuery = Number(params.q);
|
||||
|
||||
if (Number.isInteger(numericQuery) && numericQuery > 0) {
|
||||
keywordConditions.unshift({ MaestroID: numericQuery });
|
||||
}
|
||||
|
||||
conditions.push({ OR: keywordConditions });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.activateStatus === "number" &&
|
||||
Number.isInteger(params.activateStatus)
|
||||
) {
|
||||
conditions.push({ ActivateStatus: params.activateStatus });
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.accountType === "number" &&
|
||||
Number.isInteger(params.accountType)
|
||||
) {
|
||||
conditions.push({ AccountType: params.accountType });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : {};
|
||||
}
|
||||
|
||||
function buildMaestroOrderBy(
|
||||
sort: MaestroSort
|
||||
): Prisma.maestroOrderByWithRelationInput {
|
||||
switch (sort) {
|
||||
case "id-asc":
|
||||
return { MaestroID: "asc" };
|
||||
case "joinedAt-desc":
|
||||
return { AcceptClausesDateTime: "desc" };
|
||||
case "joinedAt-asc":
|
||||
return { AcceptClausesDateTime: "asc" };
|
||||
case "availableAt-desc":
|
||||
return { AvailableActivateDateTime: "desc" };
|
||||
case "availableAt-asc":
|
||||
return { AvailableActivateDateTime: "asc" };
|
||||
case "id-desc":
|
||||
default:
|
||||
return { MaestroID: "desc" };
|
||||
}
|
||||
}
|
||||
|
||||
function searchParamsToRecord(
|
||||
rawSearchParams: RawSearchParams
|
||||
): Record<string, string | undefined> {
|
||||
if (rawSearchParams instanceof URLSearchParams) {
|
||||
return Object.fromEntries(rawSearchParams.entries());
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawSearchParams).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value[0] : value,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function toMaestroListItem(maestro: {
|
||||
MaestroID: number;
|
||||
Name: string;
|
||||
Email: string;
|
||||
AccountType: number;
|
||||
ActivateStatus: number;
|
||||
AvailableActivateDateTime: Date;
|
||||
AcceptClausesDateTime: Date;
|
||||
PlayerCount: number;
|
||||
}): MaestroListItem {
|
||||
return {
|
||||
maestroID: maestro.MaestroID,
|
||||
name: maestro.Name.trim(),
|
||||
email: maestro.Email.trim(),
|
||||
accountType: maestro.AccountType,
|
||||
activateStatus: maestro.ActivateStatus,
|
||||
availableActivateDateTime: maestro.AvailableActivateDateTime.toISOString(),
|
||||
acceptClausesDateTime: maestro.AcceptClausesDateTime.toISOString(),
|
||||
playerCount: maestro.PlayerCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type MailConfig = {
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
bccAddress: string;
|
||||
sendEnabled: boolean;
|
||||
/** 설정 시 모든 수신인을 이 주소로 고정 (local/stage 테스트용) */
|
||||
overrideTo: string | undefined;
|
||||
};
|
||||
|
||||
export function getMailConfig(): MailConfig {
|
||||
return {
|
||||
smtpHost: process.env.MAIL_SMTP_HOST ?? "smtp.gmail.com",
|
||||
smtpPort: Number(process.env.MAIL_SMTP_PORT ?? "465"),
|
||||
smtpSecure: process.env.MAIL_SMTP_SECURE !== "false",
|
||||
smtpUser: process.env.MAIL_SMTP_USER ?? "",
|
||||
smtpPassword: process.env.MAIL_SMTP_PASSWORD ?? "",
|
||||
fromName: process.env.MAIL_FROM_NAME ?? "초코마에",
|
||||
fromAddress: process.env.MAIL_FROM_ADDRESS ?? "",
|
||||
bccAddress: process.env.MAIL_BCC_ADDRESS ?? "",
|
||||
sendEnabled: process.env.MAIL_SEND_ENABLED === "true",
|
||||
overrideTo: process.env.MAIL_OVERRIDE_TO || undefined,
|
||||
};
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { ACTIVATE_STATUSES } from "@/lib/constants";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getMailConfig } from "@/lib/mail-config";
|
||||
import {
|
||||
getAccountTypePlayerCount,
|
||||
getAccountTypePrice,
|
||||
} from "@/lib/utils";
|
||||
|
||||
const CTX = "mail";
|
||||
|
||||
function createTransporter() {
|
||||
const cfg = getMailConfig();
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: cfg.smtpHost,
|
||||
port: cfg.smtpPort,
|
||||
secure: cfg.smtpSecure,
|
||||
auth: {
|
||||
user: cfg.smtpUser,
|
||||
pass: cfg.smtpPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SendMailOptions = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
const isDetailedLog = () => process.env.APP_ENV !== "production";
|
||||
|
||||
async function sendMail(options: SendMailOptions): Promise<boolean> {
|
||||
const cfg = getMailConfig();
|
||||
const detailed = isDetailedLog();
|
||||
|
||||
if (!cfg.sendEnabled) {
|
||||
logger.info(CTX, "send skipped (MAIL_SEND_ENABLED=false)", {
|
||||
to: cfg.overrideTo ?? options.to,
|
||||
subject: options.subject,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const to = cfg.overrideTo ?? options.to;
|
||||
const bcc = cfg.bccAddress || undefined;
|
||||
|
||||
if (detailed) {
|
||||
logger.debug(CTX, "sending", {
|
||||
to,
|
||||
...(bcc && { bcc }),
|
||||
subject: options.subject,
|
||||
smtp: `${cfg.smtpUser}@${cfg.smtpHost}:${cfg.smtpPort}`,
|
||||
...(cfg.overrideTo && { overrideTo: cfg.overrideTo }),
|
||||
});
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const transporter = createTransporter();
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${cfg.fromName}" <${cfg.fromAddress}>`,
|
||||
replyTo: cfg.fromAddress,
|
||||
to,
|
||||
bcc,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
});
|
||||
const duration = Date.now() - t0;
|
||||
|
||||
if (detailed) {
|
||||
logger.info(CTX, "send ok", {
|
||||
to,
|
||||
subject: options.subject,
|
||||
messageId: info.messageId,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
logger.info(CTX, "send ok", { duration });
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - t0;
|
||||
|
||||
if (detailed) {
|
||||
logger.error(CTX, "send failed", {
|
||||
to,
|
||||
subject: options.subject,
|
||||
duration,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
...(err instanceof Error && err.stack && { stack: err.stack }),
|
||||
});
|
||||
} else {
|
||||
logger.error(CTX, "send failed", { duration });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendExtensionDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
newAvailableDate: string
|
||||
): Promise<boolean> {
|
||||
const subject = `[초코마에] ${maestroName} - 마에스트로 계정 유효기간 연장 완료`;
|
||||
const html = `안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
${maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
|
||||
* 새로운 유효기간 : ${newAvailableDate}<br/>
|
||||
<br/>
|
||||
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
|
||||
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
|
||||
<br/>
|
||||
감사합니다.`;
|
||||
|
||||
return sendMail({ to: maestroEmail, subject, html });
|
||||
}
|
||||
|
||||
export async function sendUpgradeDoneEmail(
|
||||
maestroName: string,
|
||||
maestroEmail: string,
|
||||
registeredActivateStatus: number,
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number,
|
||||
newAvailableDate: string
|
||||
): Promise<boolean> {
|
||||
const subject = `[초코마에] ${maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`;
|
||||
|
||||
const prevStudentCount = getAccountTypePlayerCount(registeredAccountType);
|
||||
const upgradeStudentCount = getAccountTypePlayerCount(requestedAccountType);
|
||||
const upgradePrice = getAccountTypePrice(requestedAccountType).toLocaleString("ko-KR");
|
||||
|
||||
let upgradeFromText: string;
|
||||
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
|
||||
upgradeFromText = `${prevStudentCount}명 (0원 : 무료 체험 기간)`;
|
||||
} else {
|
||||
const prevPrice = getAccountTypePrice(registeredAccountType).toLocaleString("ko-KR");
|
||||
upgradeFromText = `${prevStudentCount}명 (${prevPrice}원)`;
|
||||
}
|
||||
|
||||
const html = `안녕하세요.<br/>
|
||||
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
|
||||
<br/>
|
||||
${maestroName} - 마에스트로 계정의 요금제가 [ ${upgradeFromText} ] 에서 [ ${upgradeStudentCount}명 (${upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
|
||||
<br/>
|
||||
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
|
||||
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
|
||||
<br/>
|
||||
감사합니다.`;
|
||||
|
||||
return sendMail({ to: maestroEmail, subject, html });
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
calculateUpgradePrice,
|
||||
formatDate,
|
||||
getAccountTypeLabel,
|
||||
getAccountTypePrice,
|
||||
getTrialAccountTypeLabel,
|
||||
} from "@/lib/utils";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
|
||||
const upgradeRequestSearchSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).catch(1),
|
||||
pageSize: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => pageSizeOptions.includes(value as PageSize))
|
||||
.catch(10),
|
||||
q: z.string().trim().catch(""),
|
||||
status: z
|
||||
.union([z.literal("all"), z.coerce.number().int().min(0)])
|
||||
.default(REQUEST_STATUSES.REQUESTED)
|
||||
.catch(REQUEST_STATUSES.REQUESTED),
|
||||
});
|
||||
|
||||
type PageSize = (typeof pageSizeOptions)[number];
|
||||
export type RawSearchParams =
|
||||
| URLSearchParams
|
||||
| Record<string, string | string[] | undefined>;
|
||||
export type UpgradeRequestSearchParams = z.infer<
|
||||
typeof upgradeRequestSearchSchema
|
||||
>;
|
||||
|
||||
export type UpgradeRequestListItem = {
|
||||
maestroUpgradeID: number;
|
||||
maestroID: number;
|
||||
maestroName: string;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
requestedDateTime: string;
|
||||
availableActivateDateTime: string;
|
||||
status: number;
|
||||
additionalPrice: number;
|
||||
};
|
||||
|
||||
export type UpgradeRequestListResult = {
|
||||
items: UpgradeRequestListItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
q: string;
|
||||
status: number | "all";
|
||||
};
|
||||
|
||||
export async function getUpgradeRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<UpgradeRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseUpgradeRequestSearchParams(rawSearchParams);
|
||||
const where = buildUpgradeRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
|
||||
const [totalCount, requests] = await Promise.all([
|
||||
db.maestro_upgrade.count({ where }),
|
||||
db.maestro_upgrade.findMany({
|
||||
where,
|
||||
orderBy: { MaestroUpgradeID: "desc" },
|
||||
skip,
|
||||
take: params.pageSize,
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
MaestroID: true,
|
||||
RegisteredActivateStatus: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
maestro: {
|
||||
select: {
|
||||
Name: true,
|
||||
AvailableActivateDateTime: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("upgrade-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toUpgradeRequestListItem),
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalCount,
|
||||
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
|
||||
q: params.q,
|
||||
status: params.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function approveUpgradeRequest(
|
||||
maestroUpgradeID: number
|
||||
): Promise<{
|
||||
availableActivateDateTime: string;
|
||||
maestroName: string;
|
||||
maestroEmail: string;
|
||||
registeredActivateStatus: number;
|
||||
registeredAccountType: number;
|
||||
requestedAccountType: number;
|
||||
}> {
|
||||
return db.$transaction(async (tx) => {
|
||||
// Atomically claim the request — prevents concurrent double-approval
|
||||
const claimed = await tx.maestro_upgrade.updateMany({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
|
||||
data: { Status: REQUEST_STATUSES.APPLIED },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
const exists = await tx.maestro_upgrade.count({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
});
|
||||
throw new ApiError(
|
||||
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
|
||||
exists ? 409 : 404
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_upgrade.findUnique({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
RequestedAccountType: true,
|
||||
RegisteredActivateStatus: true,
|
||||
RegisteredAccountType: true,
|
||||
maestro: { select: { Name: true, Email: true, AccountType: true, ActivateStatus: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-verify upgrade direction at approval time (maestro AccountType may have changed since request)
|
||||
const isApprovalTrial = request!.maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||
const isStillValidUpgrade = isApprovalTrial
|
||||
? request!.RequestedAccountType >= request!.maestro.AccountType
|
||||
: request!.RequestedAccountType > request!.maestro.AccountType;
|
||||
if (!isStillValidUpgrade) {
|
||||
throw new ApiError(
|
||||
"마에스트로의 현재 요금제 기준으로 유효하지 않은 업그레이드입니다. 신청 내용을 확인해 주세요.",
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const availableActivateDateTime = calculateUpgradeAvailableDate();
|
||||
|
||||
await tx.maestro.update({
|
||||
where: { MaestroID: request!.MaestroID },
|
||||
data: {
|
||||
AccountType: request!.RequestedAccountType,
|
||||
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
|
||||
AvailableActivateDateTime: availableActivateDateTime,
|
||||
},
|
||||
});
|
||||
await tx.maestro_upgrade.updateMany({
|
||||
where: {
|
||||
MaestroID: request!.MaestroID,
|
||||
Status: REQUEST_STATUSES.REQUESTED,
|
||||
},
|
||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||
});
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "upgrade_maestro",
|
||||
MaestroID: request!.MaestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildUpgradeLogRemark(
|
||||
request!.RegisteredActivateStatus,
|
||||
request!.RegisteredAccountType,
|
||||
request!.RequestedAccountType
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
availableActivateDateTime: availableActivateDateTime.toISOString(),
|
||||
maestroName: request!.maestro.Name.trim(),
|
||||
maestroEmail: request!.maestro.Email.trim(),
|
||||
registeredActivateStatus: request!.RegisteredActivateStatus,
|
||||
registeredAccountType: request!.RegisteredAccountType,
|
||||
requestedAccountType: request!.RequestedAccountType,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUpgradeRequest(
|
||||
maestroID: number,
|
||||
requestedAccountType: number
|
||||
): Promise<{ maestroUpgradeID: number; registeredAccountType: number; requestedDateTime: string; status: number }> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const maestro = await tx.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: { ActivateStatus: true, AccountType: true },
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
||||
}
|
||||
|
||||
const isTrial = maestro.ActivateStatus === ACTIVATE_STATUSES.TRIAL;
|
||||
const isValidUpgrade = isTrial
|
||||
? requestedAccountType >= maestro.AccountType
|
||||
: requestedAccountType > maestro.AccountType;
|
||||
|
||||
if (!isValidUpgrade) {
|
||||
throw new ApiError(
|
||||
isTrial
|
||||
? "체험 계정은 동일 요금제 이상으로만 업그레이드 신청이 가능합니다."
|
||||
: "현재 요금제보다 높은 요금제로만 업그레이드 신청이 가능합니다.",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_upgrade.create({
|
||||
data: {
|
||||
MaestroID: maestroID,
|
||||
RegisteredActivateStatus: maestro.ActivateStatus,
|
||||
RegisteredAccountType: maestro.AccountType,
|
||||
RequestedAccountType: requestedAccountType,
|
||||
RequestedDateTime: new Date(),
|
||||
Status: REQUEST_STATUSES.REQUESTED,
|
||||
},
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedDateTime: true,
|
||||
Status: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "request_upgrade_maestro",
|
||||
MaestroID: maestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildUpgradeLogRemark(
|
||||
maestro.ActivateStatus,
|
||||
maestro.AccountType,
|
||||
requestedAccountType
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
maestroUpgradeID: request.MaestroUpgradeID,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedDateTime: request.RequestedDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectUpgradeRequest(
|
||||
maestroUpgradeID: number
|
||||
): Promise<void> {
|
||||
await db.$transaction(async (tx) => {
|
||||
const claimed = await tx.maestro_upgrade.updateMany({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID, Status: REQUEST_STATUSES.REQUESTED },
|
||||
data: { Status: REQUEST_STATUSES.CLOSED },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
const exists = await tx.maestro_upgrade.count({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
});
|
||||
throw new ApiError(
|
||||
exists ? "이미 처리된 업그레이드 신청입니다." : "업그레이드 신청을 찾을 수 없습니다.",
|
||||
exists ? 409 : 404
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_upgrade.findUnique({
|
||||
where: { MaestroUpgradeID: maestroUpgradeID },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
RegisteredActivateStatus: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.maestro_log.create({
|
||||
data: {
|
||||
Type: "reject_upgrade_maestro",
|
||||
MaestroID: request!.MaestroID,
|
||||
LogDateTime: new Date(),
|
||||
Remark: buildUpgradeLogRemark(
|
||||
request!.RegisteredActivateStatus,
|
||||
request!.RegisteredAccountType,
|
||||
request!.RequestedAccountType
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseUpgradeRequestSearchParams(
|
||||
rawSearchParams: RawSearchParams
|
||||
): UpgradeRequestSearchParams {
|
||||
return upgradeRequestSearchSchema.parse(
|
||||
searchParamsToRecord(rawSearchParams)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function buildUpgradeRequestWhere(
|
||||
params: UpgradeRequestSearchParams
|
||||
): Prisma.maestro_upgradeWhereInput {
|
||||
const conditions: Prisma.maestro_upgradeWhereInput[] = [];
|
||||
|
||||
if (params.q) {
|
||||
const keywordConditions: Prisma.maestro_upgradeWhereInput[] = [
|
||||
{ maestro: { Name: { contains: params.q } } },
|
||||
];
|
||||
const numericQuery = Number(params.q);
|
||||
|
||||
if (Number.isInteger(numericQuery) && numericQuery > 0) {
|
||||
keywordConditions.unshift(
|
||||
{ MaestroUpgradeID: numericQuery },
|
||||
{ MaestroID: numericQuery }
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push({ OR: keywordConditions });
|
||||
}
|
||||
|
||||
if (typeof params.status === "number" && Number.isInteger(params.status)) {
|
||||
conditions.push({ Status: params.status });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : {};
|
||||
}
|
||||
|
||||
function searchParamsToRecord(
|
||||
rawSearchParams: RawSearchParams
|
||||
): Record<string, string | undefined> {
|
||||
if (rawSearchParams instanceof URLSearchParams) {
|
||||
return Object.fromEntries(rawSearchParams.entries());
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawSearchParams).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value[0] : value,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function toUpgradeRequestListItem(request: {
|
||||
MaestroUpgradeID: number;
|
||||
MaestroID: number;
|
||||
RegisteredActivateStatus: number;
|
||||
RegisteredAccountType: number;
|
||||
RequestedAccountType: number;
|
||||
RequestedDateTime: Date;
|
||||
Status: number;
|
||||
maestro: {
|
||||
Name: string;
|
||||
AvailableActivateDateTime: Date;
|
||||
};
|
||||
}): UpgradeRequestListItem {
|
||||
return {
|
||||
maestroUpgradeID: request.MaestroUpgradeID,
|
||||
maestroID: request.MaestroID,
|
||||
maestroName: request.maestro.Name.trim(),
|
||||
registeredActivateStatus: request.RegisteredActivateStatus,
|
||||
registeredAccountType: request.RegisteredAccountType,
|
||||
requestedAccountType: request.RequestedAccountType,
|
||||
requestedDateTime: formatDate(request.RequestedDateTime),
|
||||
availableActivateDateTime:
|
||||
request.maestro.AvailableActivateDateTime.toISOString(),
|
||||
status: request.Status,
|
||||
additionalPrice: calculateAdditionalPrice(
|
||||
request.RegisteredActivateStatus,
|
||||
request.RegisteredAccountType,
|
||||
request.RequestedAccountType
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function calculateAdditionalPrice(
|
||||
registeredActivateStatus: number,
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number
|
||||
) {
|
||||
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
|
||||
return getAccountTypePrice(requestedAccountType);
|
||||
}
|
||||
|
||||
return calculateUpgradePrice(requestedAccountType, registeredAccountType);
|
||||
}
|
||||
|
||||
function calculateUpgradeAvailableDate(now = new Date()) {
|
||||
const availableDate = new Date(now);
|
||||
|
||||
availableDate.setFullYear(availableDate.getFullYear() + 1);
|
||||
|
||||
return availableDate;
|
||||
}
|
||||
|
||||
function buildUpgradeLogRemark(
|
||||
registeredActivateStatus: number,
|
||||
registeredAccountType: number,
|
||||
requestedAccountType: number
|
||||
) {
|
||||
const registeredLabel =
|
||||
registeredActivateStatus === ACTIVATE_STATUSES.TRIAL
|
||||
? getTrialAccountTypeLabel(registeredAccountType)
|
||||
: getAccountTypeLabel(registeredAccountType);
|
||||
|
||||
return `${registeredLabel} -> ${getAccountTypeLabel(requestedAccountType)}`.slice(
|
||||
0,
|
||||
100
|
||||
);
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
ACCOUNT_TYPE_LABELS,
|
||||
ACCOUNT_TYPE_PLAYER_COUNTS,
|
||||
ACCOUNT_TYPE_PRICES,
|
||||
ACTIVATE_STATUS_LABELS,
|
||||
REQUEST_STATUS_LABELS,
|
||||
TRIAL_ACCOUNT_TYPE_LABELS,
|
||||
type AccountType,
|
||||
type ActivateStatus,
|
||||
type PaidAccountType,
|
||||
type RequestStatus,
|
||||
} from "@/lib/constants";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getAccountTypeLabel(accountType: number): string {
|
||||
return (
|
||||
ACCOUNT_TYPE_LABELS[accountType as AccountType] ?? `${accountType}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getTrialAccountTypeLabel(accountType: number): string {
|
||||
return (
|
||||
TRIAL_ACCOUNT_TYPE_LABELS[accountType as PaidAccountType] ??
|
||||
`${accountType}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccountTypePlayerCount(accountType: number): number {
|
||||
return ACCOUNT_TYPE_PLAYER_COUNTS[accountType as PaidAccountType] ?? 0;
|
||||
}
|
||||
|
||||
export function getAccountTypePrice(accountType: number): number {
|
||||
return ACCOUNT_TYPE_PRICES[accountType as PaidAccountType] ?? 0;
|
||||
}
|
||||
|
||||
export function getActivateStatusLabel(activateStatus: number): string {
|
||||
return (
|
||||
ACTIVATE_STATUS_LABELS[activateStatus as ActivateStatus] ??
|
||||
`${activateStatus}? (N/A)`
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestStatusLabel(status: number): string {
|
||||
return REQUEST_STATUS_LABELS[status as RequestStatus] ?? `${status}? (N/A)`;
|
||||
}
|
||||
|
||||
export function calculateUpgradePrice(
|
||||
requestedAccountType: number,
|
||||
registeredAccountType: number
|
||||
): number {
|
||||
return (
|
||||
getAccountTypePrice(requestedAccountType) -
|
||||
getAccountTypePrice(registeredAccountType)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(value: Date | string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value: Date | string | null | undefined
|
||||
): string {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${formatDate(date)} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function calculateExtendedAvailableDate(
|
||||
availableDate: Date,
|
||||
now = new Date()
|
||||
): Date {
|
||||
const baseDate =
|
||||
availableDate.getTime() < now.getTime() ? now : availableDate;
|
||||
const extendedDate = new Date(baseDate);
|
||||
|
||||
extendedDate.setFullYear(extendedDate.getFullYear() + 1);
|
||||
extendedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return extendedDate;
|
||||
}
|
||||
|
||||
export function toDatetimeLocal(value: Date | string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "chocoadmin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:check": "tsx scripts/check-db.ts",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"mariadb": "^3.5.2",
|
||||
"next": "16.2.7",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"nodemailer": "^9.0.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.11.0",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.7",
|
||||
"prettier": "^3.8.4",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
+7083
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { config } from "dotenv";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../lib/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
}
|
||||
|
||||
model active_app {
|
||||
ActiveAppID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "active_app_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "active_app_ibfk_2")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model admin {
|
||||
AdminID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
Password String @db.Char(50)
|
||||
Email String @db.Char(50)
|
||||
AccountType Int @db.UnsignedInt
|
||||
ActivateStatus Int @db.UnsignedInt
|
||||
}
|
||||
|
||||
model ads_client {
|
||||
AdsClientID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
ClientName String @db.Char(50)
|
||||
typing_exam_ads typing_exam_ads[]
|
||||
}
|
||||
|
||||
model app {
|
||||
AppID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AppName String @db.Char(40)
|
||||
KoreanName String @db.Char(40)
|
||||
AppType Int @db.UnsignedInt
|
||||
Status Int @db.UnsignedInt
|
||||
HowToPlay String @db.Text
|
||||
active_app active_app[]
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
}
|
||||
|
||||
model app_highest_record {
|
||||
AppHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
HighestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "app_highest_record_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "app_highest_record_ibfk_2")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "app_highest_record_ibfk_3")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model banned_word {
|
||||
BannedWordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Word String @db.Char(50)
|
||||
}
|
||||
|
||||
model best_record {
|
||||
BestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
AppID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
BestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "best_record_ibfk_1")
|
||||
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "best_record_ibfk_2")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "best_record_ibfk_3")
|
||||
|
||||
@@index([AppID], map: "AppID")
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model license_maestro_password {
|
||||
LicenseMaestroPasswordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
Password Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_maestro_password_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model license_score {
|
||||
LicenseScoreID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
SubjectName String @db.Char(50)
|
||||
Score Int @db.UnsignedInt
|
||||
ScoreDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_score_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_score_ibfk_2")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
model license_time {
|
||||
LicenseTimeID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
StartTime Int @db.UnsignedInt
|
||||
LeftTime Int
|
||||
SavedDateTime DateTime @db.DateTime(0)
|
||||
SubjectName String @db.Char(50)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_time_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_time_ibfk_2")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro {
|
||||
MaestroID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
Password String @db.Char(50)
|
||||
Email String @db.Char(50)
|
||||
AccountType Int @db.UnsignedInt
|
||||
ActivateStatus Int @db.UnsignedInt
|
||||
AvailableActivateDateTime DateTime @db.DateTime(0)
|
||||
PlayerCount Int @db.UnsignedInt
|
||||
AcceptClausesDateTime DateTime @db.DateTime(0)
|
||||
AllowEditEnterCode Int @db.UnsignedInt
|
||||
MaestroTestID Int? @db.UnsignedInt
|
||||
active_app active_app[]
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
license_maestro_password license_maestro_password[]
|
||||
license_score license_score[]
|
||||
license_time license_time[]
|
||||
maestro_extension maestro_extension[]
|
||||
maestro_upgrade maestro_upgrade[]
|
||||
player player[]
|
||||
typing_exam_record typing_exam_record[]
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro_extension {
|
||||
MaestroExtensionID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AccountType Int @db.UnsignedInt
|
||||
RequestedDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_extension_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model maestro_log {
|
||||
MaestroLogID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
Type String @db.Char(50)
|
||||
MaestroID Int @db.UnsignedInt
|
||||
LogDateTime DateTime @db.DateTime(0)
|
||||
Remark String @db.Char(100)
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model maestro_upgrade {
|
||||
MaestroUpgradeID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
RegisteredActivateStatus Int @default(1) @db.UnsignedInt
|
||||
RegisteredAccountType Int
|
||||
RequestedAccountType Int @db.UnsignedInt
|
||||
RequestedDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_upgrade_ibfk_1")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model player {
|
||||
PlayerID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
Name String @db.Char(50)
|
||||
EnterCode String @db.Char(8)
|
||||
AccountType Int? @default(0) @db.UnsignedInt
|
||||
app_highest_record app_highest_record[]
|
||||
best_record best_record[]
|
||||
license_score license_score[]
|
||||
license_time license_time[]
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "player_ibfk_1")
|
||||
typing_exam_record typing_exam_record[]
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
}
|
||||
|
||||
model typing_exam_ads {
|
||||
TypingExamAdsID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
AdsClientID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
FromDateTime DateTime @db.DateTime(0)
|
||||
ToDateTime DateTime @db.DateTime(0)
|
||||
Status Int @db.UnsignedInt
|
||||
Cost Float @db.Float
|
||||
ads_client ads_client @relation(fields: [AdsClientID], references: [AdsClientID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_1")
|
||||
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_2")
|
||||
|
||||
@@index([AdsClientID], map: "AdsClientID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model typing_exam_highest_record {
|
||||
TypingExamHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
HighestRecord Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model typing_exam_record {
|
||||
TypingExamRecordID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
MaestroID Int @db.UnsignedInt
|
||||
PlayerID Int @db.UnsignedInt
|
||||
WritingID Int @db.UnsignedInt
|
||||
Record Float @db.Float
|
||||
RecordDateTime DateTime @db.DateTime(0)
|
||||
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "typing_exam_record_ibfk_1")
|
||||
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "typing_exam_record_ibfk_2")
|
||||
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_record_ibfk_3")
|
||||
|
||||
@@index([MaestroID], map: "MaestroID")
|
||||
@@index([PlayerID], map: "PlayerID")
|
||||
@@index([WritingID], map: "WritingID")
|
||||
}
|
||||
|
||||
model writing {
|
||||
WritingID Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
ActivateStatus Int
|
||||
Language String @db.Char(10)
|
||||
Name String @db.Char(50)
|
||||
Filename String @db.Char(50)
|
||||
Writer String @db.Char(50)
|
||||
WriterID Int? @db.UnsignedInt
|
||||
UpdateDate DateTime @db.DateTime(0)
|
||||
LetterCount Int @db.UnsignedInt
|
||||
typing_exam_ads typing_exam_ads[]
|
||||
typing_exam_record typing_exam_record[]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
function usesSecureAuthCookies() {
|
||||
const authUrl = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL;
|
||||
|
||||
return authUrl?.startsWith("https://") ?? false;
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
||||
const cookieName = `chocoadmin-${process.env.APP_ENV ?? "local"}.session-token`;
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
|
||||
secureCookie: usesSecureAuthCookies(),
|
||||
cookieName,
|
||||
});
|
||||
|
||||
if (!token && !isPublicPath) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", `${pathname}${search}`);
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
if (token && isPublicPath) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,87 @@
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: ".env.local" });
|
||||
|
||||
async function main() {
|
||||
const { db } = await import("@/lib/db");
|
||||
const { getAccountTypeLabel, getActivateStatusLabel, getRequestStatusLabel } =
|
||||
await import("@/lib/utils");
|
||||
|
||||
const [maestroCount, extensionCount, upgradeCount] = await Promise.all([
|
||||
db.maestro.count(),
|
||||
db.maestro_extension.count(),
|
||||
db.maestro_upgrade.count(),
|
||||
]);
|
||||
|
||||
const [maestros, extensionRequests, upgradeRequests] = await Promise.all([
|
||||
db.maestro.findMany({
|
||||
orderBy: { MaestroID: "desc" },
|
||||
select: {
|
||||
MaestroID: true,
|
||||
Name: true,
|
||||
AccountType: true,
|
||||
ActivateStatus: true,
|
||||
PlayerCount: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
db.maestro_extension.findMany({
|
||||
orderBy: { MaestroExtensionID: "desc" },
|
||||
select: {
|
||||
MaestroExtensionID: true,
|
||||
MaestroID: true,
|
||||
AccountType: true,
|
||||
Status: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
db.maestro_upgrade.findMany({
|
||||
orderBy: { MaestroUpgradeID: "desc" },
|
||||
select: {
|
||||
MaestroUpgradeID: true,
|
||||
MaestroID: true,
|
||||
RegisteredAccountType: true,
|
||||
RequestedAccountType: true,
|
||||
Status: true,
|
||||
},
|
||||
take: 3,
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log("DB connection OK");
|
||||
console.log({
|
||||
counts: {
|
||||
maestro: maestroCount,
|
||||
maestro_extension: extensionCount,
|
||||
maestro_upgrade: upgradeCount,
|
||||
},
|
||||
maestros: maestros.map((maestro) => ({
|
||||
id: maestro.MaestroID,
|
||||
name: maestro.Name,
|
||||
accountType: getAccountTypeLabel(maestro.AccountType),
|
||||
activateStatus: getActivateStatusLabel(maestro.ActivateStatus),
|
||||
playerCount: maestro.PlayerCount,
|
||||
})),
|
||||
extensionRequests: extensionRequests.map((request) => ({
|
||||
id: request.MaestroExtensionID,
|
||||
maestroId: request.MaestroID,
|
||||
accountType: getAccountTypeLabel(request.AccountType),
|
||||
status: getRequestStatusLabel(request.Status),
|
||||
})),
|
||||
upgradeRequests: upgradeRequests.map((request) => ({
|
||||
id: request.MaestroUpgradeID,
|
||||
maestroId: request.MaestroID,
|
||||
from: getAccountTypeLabel(request.RegisteredAccountType),
|
||||
to: getAccountTypeLabel(request.RequestedAccountType),
|
||||
status: getRequestStatusLabel(request.Status),
|
||||
})),
|
||||
});
|
||||
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("DB connection check failed");
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Executable
+53
@@ -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"
|
||||
Executable
+46
@@ -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"
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
admin,
|
||||
maestro,
|
||||
maestro_extension,
|
||||
maestro_log,
|
||||
maestro_upgrade,
|
||||
player,
|
||||
} from "@/lib/generated/prisma/client";
|
||||
|
||||
export type {
|
||||
AccountType,
|
||||
ActivateStatus,
|
||||
PaidAccountType,
|
||||
RequestStatus,
|
||||
} from "@/lib/constants";
|
||||
Reference in New Issue
Block a user