Compare commits

..

9 Commits

36 changed files with 3027 additions and 326 deletions
+38
View File
@@ -4,6 +4,20 @@ 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"
@@ -11,6 +25,18 @@ NEXTAUTH_URL="http://localhost:3000"
# 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"
@@ -18,3 +44,15 @@ NEXTAUTH_URL="http://localhost:3000"
# 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
+78 -2
View File
@@ -1,5 +1,81 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
---
# Project: chocoadmin
Admin panel for the chocomae service (mouse-typing), extracted into a standalone Next.js 16 app. It shares the existing MariaDB database — **do not modify the DB schema**.
## Critical rules
- **Never alter the DB schema.** All tables are shared with the live chocomae service.
- **All mutating APIs must verify current `Status = 1` before acting** (idempotency guard, returns 409 if already processed).
- **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.
- 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`.
## 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.
## 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`) |
| `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) |
`Remark` column is `char(100)` — keep values under 100 characters.
## Implemented phases (do not re-implement)
- Phase 08: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards.
- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification.
## Planned phases (not yet implemented)
- **Phase 10** — Maestro info edit form + student list with pagination (`PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students`)
- **Phase 11** — Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`)
## 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
+124 -21
View File
@@ -1,36 +1,139 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# chocoadmin
## Getting Started
chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스로 분리한 웹 애플리케이션이다.
First, run the development server:
## 기술 스택
| 구분 | 기술 |
|---|---|
| 프레임워크 | Next.js 16 (App Router) |
| 언어 | TypeScript |
| 패키지 매니저 | pnpm |
| UI / CSS | Tailwind CSS v4 + shadcn/ui (Base UI) |
| Table | TanStack Table v8 |
| ORM | Prisma v7 (MariaDB adapter) |
| 인증 | NextAuth.js v5 (admin 테이블 기반) |
| 유효성 검증 | Zod v4 |
| 이메일 | Nodemailer |
| DB | MariaDB 10.11 (기존 chocomae DB 공유) |
## 구현된 기능
- 관리자 로그인 (기존 `admin` 테이블 / MariaDB `PASSWORD()` 호환)
- 마에스트로 전체 목록 — 검색(이름·이메일·ID), 상태/계정유형 필터, 페이지네이션
- 마에스트로 상세 보기 — 기본 정보, 연장/업그레이드 이력, 처리 로그
- 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`)
- 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`)
- 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사)
- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹
## 개발 환경 설정
### 1. 의존성 설치
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
pnpm install
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### 2. 환경변수 설정
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
cp .env.example .env.local
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
`.env.local`을 열어 실제 값으로 교체한다.
## Learn More
```env
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
AUTH_SECRET="..."
NEXTAUTH_SECRET="..."
AUTH_URL="http://localhost:3000"
NEXTAUTH_URL="http://localhost:3000"
```
To learn more about Next.js, take a look at the following resources:
### 3. DB 연결 확인
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
```bash
pnpm db:check
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
### 4. 개발 서버 실행
## Deploy on Vercel
```bash
pnpm dev
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
[http://localhost:3000](http://localhost:3000) 에서 확인한다.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## 환경변수 전체 목록
| 변수 | 필수 | 설명 |
|---|---|---|
| `DATABASE_URL` | ✅ | MariaDB 연결 문자열 (`mysql://...`) |
| `AUTH_SECRET` | ✅ | NextAuth 세션 암호화 키 |
| `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) |
| `AUTH_URL` | ✅ | 서비스 기본 URL |
| `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) |
| `APP_ENV` | — | `local` \| `stage` \| `production` — 미설정 시 `debug` 레벨 기본값 |
| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` — production 권장: `info` |
| `DB_SLOW_QUERY_MS` | — | slow query 임계값(ms), 기본 `1000` — 초과 시 `warn` 로그 |
| `MAIL_FROM_NAME` | — | 발송자 이름 (Phase 11) |
| `MAIL_FROM_ADDRESS` | — | 발송자 주소 (Phase 11) |
| `MAIL_BCC_ADDRESS` | — | BCC 주소 (Phase 11) |
| `MAIL_SMTP_HOST` | — | SMTP 호스트 (Phase 11) |
| `MAIL_SMTP_PORT` | — | SMTP 포트 (Phase 11) |
| `MAIL_SMTP_SECURE` | — | TLS 여부 (Phase 11) |
| `MAIL_SMTP_USER` | — | SMTP 인증 계정 (Phase 11) |
| `MAIL_SMTP_PASSWORD` | — | SMTP 인증 비밀번호 (Phase 11) |
| `MAIL_SEND_ENABLED` | — | `true` \| `false` 발송 활성화 (Phase 11) |
## 배포 (Synology NAS Docker)
```bash
# 이미지 빌드 및 컨테이너 시작
docker compose up -d --build
```
`docker-compose.yml``environment` 섹션에 위 환경변수를 설정한다. 상세 절차는 [`docs/deployment.md`](docs/deployment.md)를 참고한다.
## 프로젝트 구조
```
chocoadmin/
├── app/
│ ├── (auth)/login/ # 관리자 로그인
│ ├── (admin)/ # 인증 필요 화면
│ │ ├── maestros/ # 마에스트로 목록·상세
│ │ ├── extension-requests/
│ │ └── upgrade-requests/
│ └── api/ # API routes
├── 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, 타이밍, 에러 처리)
│ ├── constants.ts # 상태값 상수
│ ├── maestros.ts # 마에스트로 DB 쿼리
│ ├── extension-requests.ts
│ └── upgrade-requests.ts
├── 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 배포 절차
- [`docs/phase/phase9.md`](docs/phase/phase9.md) — 로그 정책 및 Docker 로그 운영 절차
@@ -0,0 +1,254 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { 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 [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;
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);
}
}
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>
{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="flex flex-wrap items-center gap-1.5"
>
{!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
);
}
+7 -65
View File
@@ -11,6 +11,8 @@ import {
getActivateStatusLabel,
getRequestStatusLabel,
} from "@/lib/utils";
import { MaestroEditForm } from "./MaestroEditForm";
import { StudentsSection } from "./StudentsSection";
type MaestroDetailPageProps = {
params: Promise<{
@@ -81,65 +83,14 @@ export default async function MaestroDetailPage({
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
<dl className="mt-4 grid gap-x-8 gap-y-4 text-sm md:grid-cols-2 xl:grid-cols-3">
<InfoItem label="이름" value={maestro.name} />
<InfoItem label="이메일" value={maestro.email} />
<InfoItem
label="상태"
value={getActivateStatusLabel(maestro.activateStatus)}
/>
<InfoItem
label="계정 유형"
value={getAccountTypeLabel(maestro.accountType)}
/>
<InfoItem
label="DB 학생 수"
value={detail.counts.players.toLocaleString()}
/>
<InfoItem
label="PlayerCount"
value={maestro.playerCount.toLocaleString()}
/>
<InfoItem
label="사용 가능일"
value={formatDateTime(maestro.availableActivateDateTime)}
/>
<InfoItem
label="약관 동의일"
value={formatDateTime(maestro.acceptClausesDateTime)}
/>
<InfoItem
label="입장코드 수정"
value={maestro.allowEditEnterCode === 1 ? "허용" : "비허용"}
/>
<InfoItem
label="체험 MaestroID"
value={maestro.maestroTestID?.toString() ?? "-"}
/>
</dl>
</section>
<section className="rounded-lg border bg-background p-5">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.players.toLocaleString()} 20
</p>
</div>
</div>
<SimpleTable
emptyText="등록된 학생이 없습니다."
headers={["PlayerID", "이름", "입장코드", "AccountType"]}
rows={detail.players.map((player) => [
player.playerID,
player.name,
player.enterCode,
player.accountType ?? "-",
])}
<MaestroEditForm
maestro={detail.maestro}
playerCount={detail.counts.players}
/>
</section>
<StudentsSection maestroID={maestroID} />
<div className="grid gap-4 xl:grid-cols-2">
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
@@ -207,15 +158,6 @@ function SummaryCard({ label, value }: { label: string; value: string }) {
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 break-words font-medium">{value}</dd>
</div>
);
}
function SimpleTable({
headers,
rows,
+22 -21
View File
@@ -73,7 +73,7 @@ export default async function MaestrosPage({
</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_110px_auto]"
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1fr)_160px_180px_190px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
@@ -148,23 +148,6 @@ export default async function MaestrosPage({
</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">
@@ -185,9 +168,27 @@ export default async function MaestrosPage({
<MaestrosTable 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>
<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="flex flex-wrap items-center gap-1.5"
+35 -44
View File
@@ -1,67 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
approveExtensionRequest,
cancelExtensionRequest,
ExtensionRequestActionError,
} 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"]),
});
type ExtensionRequestRouteContext = {
params: Promise<{
id: string;
}>;
};
export const PATCH = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroExtensionID = Number(id);
export async function PATCH(
request: Request,
{ params }: ExtensionRequestRouteContext
) {
const session = await auth();
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
throw new ApiError("Invalid extension request id", 400);
}
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const maestroExtensionID = Number(id);
if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) {
return NextResponse.json(
{ message: "Invalid extension request id" },
{ status: 400 }
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
}
if (!body.success) {
throw new ApiError("Invalid action", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
}
try {
if (body.data.action === "approve") {
const result = await approveExtensionRequest(maestroExtensionID);
return NextResponse.json({ ok: true, ...result });
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 });
}
await cancelExtensionRequest(maestroExtensionID);
logger.info(CTX, "cancelled", {
id: maestroExtensionID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof ExtensionRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
}
}
);
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getExtensionRequests } from "@/lib/extension-requests";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
export const GET = withApiHandler("extension-requests", async (request) => {
const url = new URL(request.url);
const result = await getExtensionRequests(url.searchParams);
return NextResponse.json(result);
}
});
+50 -29
View File
@@ -1,39 +1,60 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMaestroDetail } from "@/lib/maestros";
import { getMaestroDetail, updateMaestro, updateMaestroSchema } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
import { logger } from "@/lib/logger";
type MaestroDetailRouteContext = {
params: Promise<{
id: string;
}>;
};
const CTX = "maestros/[id]";
export async function GET(
_request: Request,
{ params }: MaestroDetailRouteContext
) {
const session = await auth();
export const GET = withApiHandler<{ id: string }>(
CTX,
async (_request, { params }) => {
const { id } = await params;
const maestroID = Number(id);
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
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);
}
);
const { id } = await params;
const maestroID = Number(id);
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) {
return NextResponse.json(
{ message: "Invalid maestro id" },
{ status: 400 }
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);
}
const result = await getMaestroDetail(maestroID);
if (!result) {
return NextResponse.json({ message: "Not found" }, { status: 404 });
}
return NextResponse.json(result);
}
);
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { getMaestroStudents } from "@/lib/maestros";
import { ApiError } from "@/lib/errors";
import { withApiHandler } from "@/lib/api-handler";
const CTX = "maestros/[id]/students";
export const GET = withApiHandler<{ id: string }>(
CTX,
async (request, { params }) => {
const { id } = await params;
const maestroID = Number(id);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
throw new ApiError("Invalid maestro id", 400);
}
const url = new URL(request.url);
const result = await getMaestroStudents(maestroID, url.searchParams);
if (!result) {
throw new ApiError("Not found", 404);
}
return NextResponse.json(result);
}
);
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMaestros } from "@/lib/maestros";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
export const GET = withApiHandler("maestros", async (request) => {
const url = new URL(request.url);
const result = await getMaestros(url.searchParams);
return NextResponse.json(result);
}
});
+38 -44
View File
@@ -1,67 +1,61 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
approveUpgradeRequest,
rejectUpgradeRequest,
UpgradeRequestActionError,
} 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"]),
});
type UpgradeRequestRouteContext = {
params: Promise<{
id: string;
}>;
};
export const PATCH = withApiHandler<{ id: string }>(
CTX,
async (request, { params, t0 }) => {
const { id } = await params;
const maestroUpgradeID = Number(id);
export async function PATCH(
request: Request,
{ params }: UpgradeRequestRouteContext
) {
const session = await auth();
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
throw new ApiError("Invalid upgrade request id", 400);
}
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const maestroUpgradeID = Number(id);
if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) {
return NextResponse.json(
{ message: "Invalid upgrade request id" },
{ status: 400 }
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
}
if (!body.success) {
throw new ApiError("Invalid action", 400);
}
const body = patchBodySchema.safeParse(
await request.json().catch(() => ({}))
);
if (!body.success) {
return NextResponse.json({ message: "Invalid action" }, { status: 400 });
}
try {
if (body.data.action === "approve") {
const result = await approveUpgradeRequest(maestroUpgradeID);
return NextResponse.json({ ok: true, ...result });
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 });
}
await rejectUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "rejected", {
id: maestroUpgradeID,
duration: Date.now() - t0,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof UpgradeRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
}
}
);
+3 -10
View File
@@ -1,17 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getUpgradeRequests } from "@/lib/upgrade-requests";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
export const GET = withApiHandler("upgrade-requests", async (request) => {
const url = new URL(request.url);
const result = await getUpgradeRequests(url.searchParams);
return NextResponse.json(result);
}
});
+5
View File
@@ -3,6 +3,9 @@ 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";
const credentialsSchema = z.object({
adminName: z.string().trim().min(1),
@@ -78,9 +81,11 @@ export const {
const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
logger.warn(AUTH_CTX, "login failed", { adminName });
return null;
}
logger.info(AUTH_CTX, "login success", { adminName, adminID });
return {
id: String(adminID),
name: admin.Name,
@@ -10,7 +10,6 @@ import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { UpgradeRequestListItem } from "@/lib/upgrade-requests";
import {
formatDate,
formatDateTime,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
@@ -79,9 +78,7 @@ export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) {
{request.additionalPrice.toLocaleString()}
</span>
</BodyCell>
<BodyCell>
{formatDateTime(request.requestedDateTime)}
</BodyCell>
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
+96 -25
View File
@@ -1,43 +1,107 @@
# chocoadmin Deployment
## 1. Production Environment
## 1. Environment Files
Create `.env.production` on the production host. Do not commit it.
Create environment files on the Synology NAS. Do not commit real `.env.*` files.
Stage file path:
```bash
/volume1/docker/service/jinaju/stage-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://stage-chocoadmin.jinaju.com"
NEXTAUTH_URL="https://stage-chocoadmin.jinaju.com"
```
Production example:
```bash
DATABASE_URL="mysql://CHCOCO_ADMIN_USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
AUTH_SECRET="replace-with-production-secret"
NEXTAUTH_SECRET="replace-with-production-secret"
NEXTAUTH_URL="http://NAS_HOST_OR_DOMAIN:3000"
AUTH_URL="https://NAS_HOST_OR_DOMAIN"
NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN"
```
Create `.env.stage` on the stage host with the same keys and stage-specific values.
`AUTH_SECRET` and `NEXTAUTH_SECRET` should use the same strong random value per environment for Auth.js compatibility.
## 2. Synology Container Manager
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL. Auth.js then writes secure cookies, and `proxy.ts` reads the secure session cookie.
1. Copy the repository to the NAS.
2. Place `.env.production` in the repository root on the NAS.
3. In Container Manager, create a project from `docker-compose.yml`.
4. Build and start the project.
5. Open `http://NAS_HOST_OR_DOMAIN:3000/login`.
Equivalent CLI command:
Generate a secret on the NAS:
```bash
openssl rand -base64 32
```
## 2. Synology Stage Deployment
The stage source is deployed by pulling Git on the NAS:
```bash
cd /volume1/docker/service/jinaju/stage-chocoadmin
git pull --ff-only
```
Stage uses `docker-compose.stage.yml`, the `stage-chocoadmin` 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 down
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
```
`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: `stage-chocoadmin`
- Forward Port: `3000`
- Websockets Support: enabled
- SSL: enabled
- Force SSL: enabled
Stage URL:
```text
https://stage-chocoadmin.jinaju.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
```
The Docker build uses placeholder build-time environment variables only so Next.js can compile without committing secrets. Runtime values are read from `.env.production` by default.
If production is also routed through Nginx Proxy Manager, attach it to `proxy-network` and use the container name and port `3000` as the upstream target.
To run with stage settings:
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`.
```bash
ENV_FILE=.env.stage docker compose up -d --build
```
## 3. AWS EC2 Security Group
## 4. AWS EC2 Security Group
Allow MariaDB only from the Synology NAS public IP.
@@ -49,7 +113,7 @@ Allow MariaDB only from the Synology NAS public IP.
Do not open `3306` to `0.0.0.0/0`.
## 4. Read-Only Rehearsal
## 5. Read-Only Rehearsal
Before enabling approval/rejection operations against production DB:
@@ -59,13 +123,13 @@ Before enabling approval/rejection operations against production DB:
4. Verify login, maestro list, extension request list, and upgrade request list.
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
## 5. Smoke Checks
## 6. Smoke Checks
After container start:
After stage container start:
```bash
docker compose ps
docker compose logs -f chocoadmin
docker compose -f docker-compose.stage.yml ps
docker compose -f docker-compose.stage.yml logs -f
```
Verify these routes in the browser:
@@ -74,3 +138,10 @@ Verify these routes in the browser:
- `/maestros`
- `/extension-requests`
- `/upgrade-requests`
If the browser reports too many redirects after login:
1. Confirm `AUTH_URL` and `NEXTAUTH_URL` exactly match the public URL and scheme.
2. Confirm Nginx Proxy Manager forwards to `stage-chocoadmin:3000` with scheme `http`.
3. Recreate the container after changing `.env.stage`.
4. Clear cookies for the stage domain.
+176
View File
@@ -0,0 +1,176 @@
# Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화
## 목표
`/maestros/[maestroId]` 상세 화면에서 마에스트로 정보를 직접 수정할 수 있는 폼을 추가하고, 학생 목록을 서버 사이드 페이지네이션과 이름 검색이 가능한 형태로 완성한다.
---
## 1. 마에스트로 상세 화면 수정 폼 구현
### 사전 조사 결과 (기존 mouse-typing 코드 확인 완료)
| 조사 항목 | 파일 위치 | 결과 |
|---|---|---|
| 이름 중복 검사 기준 | `server/maestro/update_maestro_info.php` | **중복 검사 없음** — 이름을 그대로 UPDATE. chocoadmin에서 신규 추가. |
| 이메일 변경 처리 | `server/maestro/update_maestro_info.php` | 변경 시 이메일 안내 메일 발송 + `maestro_log` 기록. 메일은 Phase 11에서 구현. |
| 입장코드 수정 컬럼 | `server/maestro/set_allow_edit_entercode.php` | `maestro.AllowEditEnterCode`: `0`(비허용) / `1`(허용). 로그 기록 없음. |
| 상태/계정유형 변경 로그 | `server/admin/` 전체 | 기존 관리자에서 상태/계정유형/사용가능일을 직접 수정하는 기능 없음. 신규 기능이므로 신규 로그 타입 정의. |
정의한 신규 로그 타입 (기존 코드에 없음):
| Type | 대상 필드 | Remark 형식 |
|---|---|---|
| `update_maestro_status` | `ActivateStatus` | `{prev} -> {new}` (숫자) |
| `update_maestro_account_type` | `AccountType` | `{prev} -> {new}` (숫자) |
| `update_maestro_available_date` | `AvailableActivateDateTime` | `{YYYY-MM-DD} -> {YYYY-MM-DD}` |
| `update_maestro_allow_enter_code` | `AllowEditEnterCode` | `{prev} -> {new}` (0 or 1) |
### API 구현: `PATCH /api/maestros/[id]` ✅
파일: `app/api/maestros/[id]/route.ts` (기존 GET과 동일 파일에 PATCH 추가)
서비스 레이어: `lib/maestros.ts``updateMaestroSchema`, `UpdateMaestroPayload`, `updateMaestro()` 추가
**요청 스키마 (Zod)**
| 필드 | 타입 | 규칙 |
|---|---|---|
| `name` | `string` | 1자 이상, trim |
| `email` | `string` | 이메일 형식 |
| `activateStatus` | `0 \| 1 \| 2 \| 100` | `ACTIVATE_STATUSES` 상수 |
| `accountType` | `1 \| 2 \| 3 \| 4 \| 5` | 유료 타입만 허용 (`PaidAccountType`) |
| `availableActivateDateTime` | `string` | ISO datetime 또는 `YYYY-MM-DD` 형식 |
| `allowEditEnterCode` | `0 \| 1` | 정수 |
모든 필드는 optional — 전달된 필드만 업데이트한다.
**처리 순서**
1. `withApiHandler`로 인증 자동 검사
2. 대상 maestro 존재 확인 (`findUnique`) — 없으면 404
3. `name`이 포함된 경우: 동일 이름의 다른 마에스트로 존재 여부 확인 — 있으면 409
4. 변경 필드 추출 (전달값 vs 현재값 비교, 동일한 값이면 변경 대상에서 제외)
5. 변경 필드가 없으면 200 + `{ changed: false }` 반환 (DB 업데이트 및 로그 기록 생략)
6. 트랜잭션: `maestro` 업데이트 + `maestro_log` INSERT (변경된 필드별로 각각 로그 기록)
7. 성공 응답: 200 + `{ changed: true }`
**응답 정책**
| 상황 | HTTP | 비고 |
|---|---|---|
| 변경 없음 | 200 | `{ changed: false }` |
| 성공 | 200 | `{ changed: true }` |
| 이름 중복 | 409 | 자기 자신은 중복으로 처리하지 않음 |
| 존재하지 않는 마에스트로 | 404 | |
| 유효하지 않은 값 | 400 | Zod 파싱 실패 |
| 인증 없음 | 401 | `withApiHandler` 처리 |
### UI 구현: 수정 폼 ✅
파일: `app/(admin)/maestros/[maestroId]/MaestroEditForm.tsx` (신규 Client Component)
현재 상세 페이지(`page.tsx`)의 "기본 정보" 섹션을 읽기 전용 표시에서 편집 가능한 폼으로 교체한다.
**필드 구성**
| 필드 | 컴포넌트 | 비고 |
|---|---|---|
| 이름 | `<Input type="text">` | |
| 이메일 | `<Input type="email">` | |
| 상태 | `<Select>` | `ACTIVATE_STATUS_LABELS` 기반 |
| 계정유형 | `<Select>` | `ACCOUNT_TYPE_LABELS` 기반, 유료 타입만 |
| 사용 가능일 | `<Input type="datetime-local">` | |
| 입장코드 수정 | `<Select>` | 허용(1) / 비허용(0) |
**제출 흐름**
1. 저장 버튼 클릭 → 확인 다이얼로그 표시
2. 확인 → `PATCH /api/maestros/[id]` 호출, 저장 버튼 비활성화
3. 성공 → 성공 토스트 + `router.refresh()`로 화면 갱신
4. 실패 → 실패 토스트 (이름 중복 409 응답 시 "이미 사용 중인 이름입니다." 별도 메시지)
---
## 2. 학생 목록 구현
### API 구현: `GET /api/maestros/[id]/students` ✅
파일: `app/api/maestros/[id]/students/route.ts` (신규)
서비스 레이어: `lib/maestros.ts``studentSearchSchema`, `MaestroStudentsResult`, `getMaestroStudents()` 추가
**쿼리 파라미터**
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|------|---|
| `page` | number | `1` | 페이지 번호 |
| `pageSize` | `10 \| 20 \| 50` | `10` | 페이지 크기 |
| `q` | string | `""` | 이름 검색 (`LIKE %q%`) |
**응답 형식**
```ts
{
items: Array<{
playerID: number;
name: string;
enterCode: string;
accountType: number | null;
}>;
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
}
```
**처리 순서**
1. `withApiHandler`로 인증 검사
2. maestro 존재 확인 — 없으면 404
3. 쿼리 파라미터 Zod 파싱
4. `player` 테이블에서 `MaestroID` 조건 + 이름 `LIKE` 검색 + 페이지네이션 조회
5. 정렬: `PlayerID DESC`
### UI 구현: 학생 목록 ✅
현재 "학생 목록 요약" 섹션(최근 20명 고정)을 서버 사이드 페이지네이션 목록으로 교체한다.
파일: `app/(admin)/maestros/[maestroId]/StudentsSection.tsx` (신규 Client Component)
**구성 요소**
- 이름 검색 입력 (`<Input>`) + 검색 버튼
- 전체 학생 수 표시 (`전체 N명`)
- 학생 목록 테이블 (`PlayerID`, `이름`, `입장코드`)
- 페이지네이션 컨트롤 — 마에스트로 목록과 동일한 슬라이딩 윈도우 형식 (현재 페이지 ±2, 처음/끝 이동, ±5 그룹 이동)
- 빈 목록 상태: "등록된 학생이 없습니다."
- 검색 결과 없음 상태: "검색 결과가 없습니다."
**동작**
- 검색어 입력 후 Enter 또는 검색 버튼 클릭으로 조회 (실시간 debounce 없음)
- 검색어 변경 시 page를 1로 초기화
- 페이지 이동은 URL query string 없이 컴포넌트 state로 관리
---
## 3. 검증 ✅
| 항목 | 방법 | 기대 결과 | 실행 결과 |
|---|---|---|---|
| 이름 중복 저장 실패 | 다른 마에스트로와 동일한 이름으로 저장 시도 | 409, "이미 사용 중인 이름입니다." | ✅ 409 + `{"message":"이미 사용 중인 이름입니다."}` |
| 이름 자기 자신과 동일 | 현재 이름 그대로 저장 | 변경 없음 처리 또는 중복 아님으로 정상 처리 | ✅ 200 + `{"changed":false}` |
| 정보 수정 성공 후 DB 반영 | 이름 / 이메일 / 상태 / 계정유형 / 사용 가능일 / 입장코드 수정 변경 후 DB 직접 조회 | 변경값 반영 확인 | ✅ 모든 필드 DB 반영 확인 |
| maestro_log 기록 확인 | 정보 수정 성공 후 `maestro_log` 조회 | 변경 필드별 `Type`, `Remark` 형식이 §1 정의와 일치 | ✅ 6개 Type 모두 정확한 형식으로 기록됨 |
| 변경 없는 저장 | 현재 값 그대로 저장 시도 | `{ changed: false }` 응답, `maestro_log` 미기록 | ✅ 200 + `{"changed":false}`, 로그 없음 |
| 학생 목록 페이지네이션 | 학생 수가 pageSize 초과인 마에스트로에서 다음 페이지 이동 | 정확한 레코드 표시, `totalCount` 일치 | ✅ 823명 마에스트로: totalPages=83, page=2 정상 |
| 학생 목록 이름 검색 | 존재하는 이름 일부로 검색 | 해당 학생만 필터링 | ✅ `q=최건` → totalCount=1, 해당 학생만 반환 |
| 학생 목록 검색 결과 없음 | 존재하지 않는 이름으로 검색 | 빈 상태 메시지 표시 | ✅ totalCount=0, items=[] |
| 비인증 접근 차단 | 세션 없이 `PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students` 호출 | 401 응답 | ✅ 401 + `{"message":"Unauthorized"}` |
### 부가 수정 사항
- `proxy.ts` matcher 수정: `api/auth``api/` (API 라우트 전체를 미들웨어 제외 대상으로 변경)
- 기존: API 라우트에 미들웨어가 개입하여 307 리다이렉트 반환
- 수정 후: API 라우트는 `withApiHandler`가 직접 401 처리
+321
View File
@@ -0,0 +1,321 @@
# Phase 11. 이메일 발송 환경 및 자동 발송
## 목표
연장 승인 / 업그레이드 승인 완료 시 마에스트로에게 안내 이메일을 자동 발송한다.
발송은 DB 커밋 성공 후 트랜잭션 바깥에서 수행하며, 발송 실패가 승인 결과에 영향을 주지 않는다.
---
## 1. 사전 조사 결과 (기존 mouse-typing 코드 확인)
### SMTP 설정 (`mail_setting.php`, `send_jinaju_mail.php`)
| 항목 | 값 |
|---|---|
| SMTP host | `smtp.gmail.com` |
| Port | `465` |
| Secure | SMTPS (implicit TLS) |
| Auth username | `support+chocomae@jinaju.com` |
| From name | `초코마에` |
| From address | `support+chocomae@jinaju.com` |
| Reply-to | `support+chocomae@jinaju.com` |
| BCC | `support+chocomae.automail@jinaju.com` |
Gmail App Password는 환경변수 `MAIL_SMTP_PASSWORD`로 분리. 코드에 하드코딩하지 않는다.
### 연장 완료 이메일 (`extensionDone`, `mail_contents.php:165`)
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정 유효기간 연장 완료`
- **본문 변수**: `{maestro_name}`, `{new_available_date}` (YYYY-MM-DD 형식)
- **본문 내용**:
- `{maestro_name} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.`
- `새로운 유효기간 : {new_available_date}`
- 상위 요금제 업그레이드 안내, 6개월 후 자동 삭제 안내
### 업그레이드 완료 이메일 (2가지 케이스, `mail_contents.php:209,255`)
업그레이드 전 상태(`RegisteredActivateStatus`)에 따라 본문이 다르다.
**케이스 A — 체험 → 유료** (`trialMaestroUpgradeDone`, `RegisteredActivateStatus = 1`)
- **제목**: `[초코마에] {maestro_name} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
- **본문 핵심**: `[ {prev_student_count}명 (0원 : 무료 체험 기간) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
- `{prev_price}` 없음 — 체험 기간이므로 0원 고정 문자열 사용
**케이스 B — 유료 → 유료** (`upgradeDone`, `RegisteredActivateStatus = 2`)
- **제목**: 케이스 A와 동일
- **본문 핵심**: `[ {prev_student_count}명 ({prev_price}원) ] 에서 [ {upgrade_student_count}명 ({upgrade_price}원) ] 으로 업그레이드`
- `{prev_price}` 있음
두 케이스 모두 공통 마무리: `마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.`
---
## 2. 메일 발송 정책
| 항목 | 정책 |
|---|---|
| **발송 시점** | DB 커밋 완료 후, 트랜잭션 바깥에서 발송 |
| **발송 실패 처리** | `logger.error`로 기록, 승인 응답은 200으로 그대로 반환 (롤백 없음) |
| **중복 발송 방지** | 승인 API는 이미 처리된 건(`Status ≠ 1`)에 409를 반환하므로 발송 자체가 중복 호출되지 않음 |
| **발송 비활성화** | `MAIL_SEND_ENABLED=false`이면 발송을 건너뛰고 info 로그만 남김 |
| **민감정보 마스킹** | SMTP 비밀번호는 환경변수로 관리, 로그에는 수신자 주소와 제목만 출력 |
---
## 3. 구현 순서
### 3-1. 패키지 설치
```bash
pnpm add nodemailer
pnpm add -D @types/nodemailer
```
nodemailer는 Node.js 런타임에서만 동작하므로 Next.js API Route(서버 측)에서만 호출한다.
### 3-2. 환경변수 추가 (`.env.local`)
```dotenv
MAIL_SMTP_HOST=smtp.gmail.com
MAIL_SMTP_PORT=465
MAIL_SMTP_SECURE=true
MAIL_SMTP_USER=support+chocomae@jinaju.com
MAIL_SMTP_PASSWORD=<gmail_app_password>
MAIL_FROM_NAME=초코마에
MAIL_FROM_ADDRESS=support+chocomae@jinaju.com
MAIL_BCC_ADDRESS=support+chocomae.automail@jinaju.com
MAIL_SEND_ENABLED=false # local/stage 발송 테스트 전까지 false 유지
```
### 3-3. `lib/mail.ts` 구현 (신규)
```
lib/mail.ts
```
**내부 구조**
```
createTransporter() — nodemailer transport 생성 (환경변수 기반)
sendMail(options) — 공통 발송 함수 (MAIL_SEND_ENABLED 체크, 로그, BCC)
sendExtensionDoneEmail(...) — 연장 완료 이메일
sendUpgradeDoneEmail(...) — 업그레이드 완료 이메일 (체험/유료 케이스 내부 분기)
```
**`sendExtensionDoneEmail` 시그니처**
```typescript
sendExtensionDoneEmail(
maestroName: string,
maestroEmail: string,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
```
**`sendUpgradeDoneEmail` 시그니처**
```typescript
sendUpgradeDoneEmail(
maestroName: string,
maestroEmail: string,
registeredActivateStatus: number, // ACTIVATE_STATUSES.TRIAL(1) or ACTIVE(2)
registeredAccountType: number,
requestedAccountType: number,
newAvailableDate: string // YYYY-MM-DD
): Promise<boolean>
```
내부에서 `registeredActivateStatus === ACTIVATE_STATUSES.TRIAL`이면 체험 케이스 본문을 사용한다.
계정별 학생 수와 가격은 `getAccountTypePlayerCount()`, `getAccountTypePrice()` (`lib/utils.ts`)를 호출한다.
### 3-4. `lib/extension-requests.ts` 수정
`approveExtensionRequest`의 반환 타입에 이메일 발송에 필요한 정보를 추가한다.
`getActionTarget`이 이미 `include: { maestro: true }`를 사용하므로 추가 쿼리 없이 `request.maestro.Email`을 사용할 수 있다.
**변경 전**
```typescript
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }>
```
**변경 후**
```typescript
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}>
```
트랜잭션 내부 마지막에 반환값에 `maestroName`, `maestroEmail` 추가:
```typescript
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
};
```
### 3-5. `lib/upgrade-requests.ts` 수정
현재 `getActionTarget``maestro_upgrade`만 조회하고 `maestro`를 포함하지 않는다. include를 추가해야 한다.
**`getActionTarget` 변경**
```typescript
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } }, // 추가
});
```
**`approveUpgradeRequest` 반환 타입 변경**
```typescript
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}>
```
트랜잭션 반환값에 추가:
```typescript
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
};
```
### 3-6. API route 수정
**`app/api/extension-requests/[id]/route.ts`**
```typescript
if (body.data.action === "approve") {
const result = await approveExtensionRequest(maestroExtensionID);
logger.info(CTX, "approved", { id: maestroExtensionID, duration: Date.now() - t0 });
// 메일 발송 (실패해도 응답에 영향 없음)
void sendExtensionDoneEmail(
result.maestroName,
result.maestroEmail,
formatDate(result.availableActivateDateTime),
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
}
```
**`app/api/upgrade-requests/[id]/route.ts`**
```typescript
if (body.data.action === "approve") {
const result = await approveUpgradeRequest(maestroUpgradeID);
logger.info(CTX, "approved", { id: maestroUpgradeID, duration: Date.now() - t0 });
void sendUpgradeDoneEmail(
result.maestroName,
result.maestroEmail,
result.registeredActivateStatus,
result.registeredAccountType,
result.requestedAccountType,
formatDate(result.availableActivateDateTime),
);
return NextResponse.json({ ok: true, availableActivateDateTime: result.availableActivateDateTime });
}
```
`void`를 붙여 메일 발송을 백그라운드로 실행하되, `sendMail` 내부에서 `await`로 실제 발송을 기다린다. 메일 발송 실패는 `logger.error`로만 처리한다.
---
## 4. 이메일 본문 (참조용)
### 연장 완료
**제목**: `[초코마에] {maestroName} - 마에스트로 계정 유효기간 연장 완료`
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
* 새로운 유효기간 : {newAvailableDate}<br/>
<br/>
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
<br/>
감사합니다.
```
### 업그레이드 완료 — 체험 → 유료
**제목**: `[초코마에] {maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 (0원 : 무료 체험 기간) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
```
### 업그레이드 완료 — 유료 → 유료
**제목**: 위와 동일
```html
안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
{maestroName} - 마에스트로 계정의 요금제가 [ {prevStudentCount}명 ({prevPrice}원) ] 에서 [ {upgradeStudentCount}명 ({upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.
```
---
## 5. 검증 절차
| 항목 | 방법 | 기대 결과 |
|---|---|---|
| `MAIL_SEND_ENABLED=false` 상태에서 승인 | 연장/업그레이드 승인 API 호출 | 승인 성공, 메일 발송 없음, `info: send skipped` 로그 출력 |
| `MAIL_SEND_ENABLED=true` — 연장 승인 이메일 | 연장 승인 후 수신 메일함 확인 | 제목/본문/수신자/BCC 형식 일치 |
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (체험) | 체험 마에스트로 업그레이드 승인 | `0원 : 무료 체험 기간` 문구 포함, 제목/본문 일치 |
| `MAIL_SEND_ENABLED=true` — 업그레이드 승인 이메일 (유료) | 유료 마에스트로 업그레이드 승인 | `{prev_price}원` 포함, 제목/본문 일치 |
| SMTP 오류 시 응답 | SMTP 연결 실패 환경에서 승인 | 승인 API 200 응답, `error: send failed` 로그 |
| 로그 민감정보 확인 | 발송 로그 출력 | 수신자 주소, 제목만 출력. SMTP 비밀번호 미포함 |
+139
View File
@@ -0,0 +1,139 @@
# Phase 8. 검증 및 안정화
## 1. 목표
`docs/plan/project-plan.md``Phase 8. 검증 및 안정화`를 수행하기 위한 검증 항목과 실행 절차를 정리한다.
Phase 8의 목표는 다음 항목을 확인하는 것이다.
- 승인/취소/거절 API가 트랜잭션으로 처리되는지 확인
- 이미 처리된 신청을 재처리할 때 `409` 오류로 막히는지 확인
- 비인증 접근이 차단되는지 확인
- Synology NAS Docker 재시작 후 정상 동작하는지 확인
## 2. 코드 검증 결과
### 2-1. 연장 신청 처리
대상 파일:
- `lib/extension-requests.ts`
- `app/api/extension-requests/[id]/route.ts`
확인 내용:
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
- 취소 처리는 `db.$transaction()` 안에서 실행된다.
- 처리 대상 조회 시 `maestro_extension.Status``REQUEST_STATUSES.REQUESTED`인지 확인한다.
- 이미 처리된 건은 `ExtensionRequestActionError`를 던지고 API route에서 `409`로 응답한다.
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
### 2-2. 업그레이드 신청 처리
대상 파일:
- `lib/upgrade-requests.ts`
- `app/api/upgrade-requests/[id]/route.ts`
확인 내용:
- 승인 처리는 `db.$transaction()` 안에서 실행된다.
- 거절 처리는 `db.$transaction()` 안에서 실행된다.
- 처리 대상 조회 시 `maestro_upgrade.Status``REQUEST_STATUSES.REQUESTED`인지 확인한다.
- 이미 처리된 건은 `UpgradeRequestActionError`를 던지고 API route에서 `409`로 응답한다.
- API route는 `auth()` 세션이 없으면 `401`로 응답한다.
### 2-3. 인증 프록시
대상 파일:
- `proxy.ts`
확인 내용:
- `/login`만 공개 경로로 둔다.
- 비로그인 사용자가 관리자 경로에 접근하면 `/login?callbackUrl=...`로 이동한다.
- HTTPS 배포 환경에서 Auth.js secure session cookie를 읽도록 `secureCookie`를 설정한다.
## 3. 실행 검증 절차
### 3-1. 로컬 기본 검증
```bash
pnpm lint
pnpm build
pnpm db:check
```
### 3-2. 비인증 접근 차단 확인
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
```bash
curl -I https://stage-chocoadmin.jinaju.com/maestros
```
기대 결과:
- `/login?callbackUrl=%2Fmaestros`로 리다이렉트된다.
### 3-3. 로그인 후 화면 확인
브라우저에서 확인한다.
- `https://stage-chocoadmin.jinaju.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/jinaju/stage-chocoadmin
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
```
기대 결과:
- `stage-chocoadmin` 컨테이너가 `Up` 상태다.
- 로그에 Next.js ready 메시지가 출력된다.
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
## 4. 안정화 메모
- 스테이지는 `proxy-network`를 통해 Nginx Proxy Manager와 연결한다.
- 스테이지 컨테이너는 host port `3000`을 직접 bind하지 않고 container port `3000`만 노출한다.
- `AUTH_URL``NEXTAUTH_URL`은 실제 외부 접속 URL과 scheme까지 정확히 일치해야 한다.
- HTTPS 배포 환경의 로그인 루프는 secure cookie 이름 불일치가 원인일 수 있다.
+144
View File
@@ -0,0 +1,144 @@
# Phase 9. 실행 로그 및 DB 쿼리 로그
## 로그 정책
### 환경 구분
| `APP_ENV` | 설명 |
|---|---|
| `local` | 맥미니 개발 환경 |
| `stage` | NAS에서 stage DB로 구동하는 검증 환경 |
| `production` | NAS에서 운영 DB로 구동하는 실제 서비스 환경 |
`NODE_ENV`는 Next.js 빌드 방식(dev/prod)을 제어하고, `APP_ENV`는 로그 레벨·상세도를 제어한다.
### 로그 레벨
| `LOG_LEVEL` | 출력 대상 |
|---|---|
| `debug` | debug + info + warn + error |
| `info` | info + warn + error |
| `warn` | warn + error |
| `error` | error만 |
`LOG_LEVEL`을 명시하지 않으면 `APP_ENV=production`일 때 `info`, 그 외는 `debug`가 기본값.
### 출력 형식
```
[2026-06-22T10:00:00.000Z] [INFO ] [maestros] list fetched {"totalCount":120,"duration":45}
[2026-06-22T10:00:00.000Z] [WARN ] [extension-requests/[id]] 이미 처리된 연장 신청입니다. {"id":5,"status":409,"duration":12}
[2026-06-22T10:00:00.000Z] [ERROR] [upgrade-requests/[id]] unexpected error {"id":3,"error":"...","duration":5}
```
- `warn` / `error`는 stderr, 그 외는 stdout으로 출력한다.
- JSON 데이터는 한 줄로 직렬화한다 (Docker 로그 파싱 편의).
### 민감정보 마스킹 대상
아래 값은 로그에 포함하지 않는다:
- 관리자 패스워드 (form 입력값, DB 조회 파라미터)
- `AUTH_SECRET` / `NEXTAUTH_SECRET`
- `DATABASE_URL` (패스워드 포함)
- 세션 토큰 / JWT 페이로드 원문
- SMTP 패스워드
현재 `auth.ts`의 raw SQL 파라미터에 패스워드가 포함될 수 있으므로 DB query 이벤트 핸들러 구현 시 `PASSWORD(` 포함 쿼리의 params를 마스킹한다 (Phase 9 DB query 로그 구현 시 처리).
---
## 환경변수
| 변수 | 설명 | 기본값 |
|---|---|---|
| `APP_ENV` | 환경 구분 (`local`\|`stage`\|`production`) | `local` |
| `LOG_LEVEL` | 최소 로그 레벨 (`debug`\|`info`\|`warn`\|`error`) | `APP_ENV`에 따라 자동 |
| `DB_SLOW_QUERY_MS` | DB 쿼리 slow query 임계값(ms) — DB query 로그 구현 시 사용 | `1000` |
### 권장 `.env` 설정
**.env.local (개발)**
```env
APP_ENV=local
LOG_LEVEL=debug
```
**.env.stage (검증)**
```env
APP_ENV=stage
LOG_LEVEL=debug
```
**.env.production (운영)**
```env
APP_ENV=production
LOG_LEVEL=info
DB_SLOW_QUERY_MS=500
```
---
## 적용 현황
### 완료
- `lib/logger.ts` — 공통 logger 모듈 (레벨 필터링, 포맷, stderr/stdout 분기)
- 로그인 성공/실패 로그 (`auth.ts`)
- 마에스트로 목록/상세 조회 로그
- 연장 신청 조회/승인/취소 로그 (409는 warn, 그 외 예외는 error)
- 업그레이드 신청 조회/승인/거절 로그 (409는 warn, 그 외 예외는 error)
### 미완료 (별도 확인 후 진행)
- **Correlation ID**: Edge Middleware(`proxy.ts`)에서 `x-correlation-id` 헤더 생성 → API route에서 AsyncLocalStorage로 전달하는 구조. 구현 전 설계 확정 필요.
- **DB query 로그**: `@prisma/adapter-mariadb` + Prisma query 이벤트 호환성 확인 필요. `$on('query', handler)` 발화 여부를 local에서 테스트한 뒤 진행.
- **결과 row/count 로그**: Prisma query 이벤트에 결과 데이터가 없음. `$extends` query 컴포넌트 방식으로 재설계하거나 계획 범위 축소 필요.
---
## Docker / NAS 로그 운영 절차
### 애플리케이션 로그 확인
```bash
# 실시간 스트리밍
docker compose logs -f chocoadmin
# 최근 100줄
docker compose logs --tail=100 chocoadmin
# 특정 시간 이후
docker compose logs --since="2026-06-22T10:00:00" chocoadmin
```
### warn/error만 필터링
```bash
docker compose logs -f chocoadmin 2>&1 | grep -E '\[(WARN |ERROR)\]'
```
### Nginx Proxy Manager 로그와 함께 확인
Synology NAS에서 Nginx Proxy Manager(NPM)와 chocoadmin 로그를 같이 보려면:
```bash
# NPM access log (컨테이너 이름은 환경에 맞게 조정)
docker logs nginx-proxy-manager 2>&1 | tail -50
# 두 로그를 동시에
docker compose logs -f chocoadmin &
docker logs -f nginx-proxy-manager
```
NPM의 access log에는 클라이언트 IP, HTTP method, path, status code, 응답 시간이 기록되고, chocoadmin 로그에는 인증 결과와 비즈니스 처리 결과가 기록된다. `status=409`를 NPM 로그에서 발견했을 때 chocoadmin의 `[WARN]` 로그와 함께 원인을 추적한다.
### stage/production 로그 레벨 차이
| 항목 | stage | production |
|---|---|---|
| `LOG_LEVEL` | `debug` | `info` |
| 요청 시작 로그 | 출력 (debug) | 미출력 |
| 성공 응답 로그 | 출력 (info) | 출력 (info) |
| 인증 실패/409 로그 | 출력 (warn, stderr) | 출력 (warn, stderr) |
| 예외/500 로그 | 출력 (error, stderr) | 출력 (error, stderr) |
+59
View File
@@ -0,0 +1,59 @@
# Chocomae Email Sending Report
조사 기준:
- 현재 `chocoadmin` 저장소에는 이메일 발송 구현이 없다.
- 기존 서비스 원본은 `/Users/jisangs/work/jinaju/git/web/mouse-typing` 기준으로 확인했다.
- 핵심 구현 파일:
- `src/web/server/mail/send_jinaju_mail.php`
- `src/web/server/mail/mail_contents.php`
- `src/web/server/mail/mail_setting.php`
## 공통 발송 설정
- 발송자 이름: `초코마에`
- 발송자 주소: `support+chocomae@jinaju.com`
- BCC: `support+chocomae.automail@jinaju.com`
- 기존 구현은 PHPMailer + SMTP를 사용한다.
- 일부 배치 스크립트는 NCloud Mail API 템플릿 ID를 사용한다.
- 기존 코드에는 SMTP/API 키가 하드코딩되어 있으므로, 신규 구현 시 반드시 환경변수 또는 배포 시크릿으로 분리해야 한다.
## 운영 플로우에서 호출되는 메일
| # | 메일 | 호출 위치 | 제목 | 주요 내용 |
|---|---|---|---|---|
| 1 | 마에스트로 체험 등록 완료 | `src/web/server/maestro/add_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 신청 아이디, 요금제, 2주 무료 체험 종료일, 계정 정보 변경 안내 |
| 2 | 관리자 등록 승인 완료 | `src/web/server/admin/register_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 등록 및 활성화 완료` | 계정 활성화 완료, 1년 사용, 업그레이드/만료/정보 변경 안내 |
| 3 | 연장 신청 입금 안내 | `src/web/server/maestro/request_extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장을 위한 입금 안내` | 현재 요금제, 기존 유효기간, 새 유효기간, 입금 계좌, 금액, 송금자명 안내 |
| 4 | 연장 완료 | `src/web/server/admin/extension_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 유효기간 연장 완료` | 유효기간 +1년 연장 완료, 새 유효기간 안내 |
| 5 | 체험 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 무료 체험 요금제, 신청 업그레이드 요금제, 전체 결제 금액, 입금 계좌 안내 |
| 6 | 일반 계정 업그레이드 입금 안내 | `src/web/server/maestro/request_upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정 업그레이드를 위한 입금 안내` | 현재 요금제, 업그레이드 요금제, 차액 결제 금액, 입금 계좌 안내 |
| 7 | 체험 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 무료 체험 요금제에서 유료 요금제로 업그레이드 완료, 유효기간 오늘부터 1년 |
| 8 | 일반 계정 업그레이드 완료 | `src/web/server/admin/upgrade_maestro.php` | `[초코마에] {{maestro_name}} - 마에스트로 계정의 요금제 업그레이드 완료 알림` | 기존 요금제에서 상위 요금제로 변경 완료, 유효기간 오늘부터 1년 |
| 9 | 마에스트로 아이디 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] {{update_maestro_name}} - 마에스트로 계정 아이디 정보 변경 알림` | 변경 전/후 아이디, 다음 로그인 시 변경된 아이디 사용 안내 |
| 10 | 마에스트로 이메일 변경 알림 | `src/web/server/maestro/update_maestro_info.php` | `[초코마에] 마에스트로 계정 이메일 정보 변경 알림` | 변경 전/후 이메일, 이후 알림은 변경 후 이메일로 발송 안내 |
## 배치/만료 관련 메일
| # | 메일 | 호출 위치 | 제목 또는 템플릿 | 주요 내용 |
|---|---|---|---|---|
| 11 | 유효기간 7일 전 만료 안내 | `src/php-cli/batch/send_mail_to_7days_left_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 - 7일 후 만료 안내` | 유효기간, 7일 후 휴면 전환, 연장/업그레이드 방법 안내 |
| 12 | 유효기간 종료 안내 | `src/php-cli/batch/send_mail_to_overdated_maestro.php` | `[초코마에] {{maestro_name}} 마에스트로 계정 유효기간 종료 안내` | 휴면 전환, 학생 로그인 불가, 6개월 후 삭제 가능성, 재활성화 방법 안내 |
| 13 | 미입금 계정 삭제 예고 | `src/php-cli/batch/old/send_mail_to_unpaid_maestro.php`, Python wrapper `send_mail_to_unpaid_maestro.py` | NCloud templateSid `337`; PHPMailer 템플릿 제목은 `[초코마에] {{maestro_name}} - 미입금 계정 삭제 예고 안내` | 신청 후 일주일간 미입금, 오늘 중 입금 없으면 계정 등록 정보 삭제 예정 |
| 14 | 유효기간 30일 전 안내 | `src/php-cli/batch/old/send_mail_to_30days_left_maestro.php`, Python wrapper `send_mail_to_expire_maestro.py` | NCloud templateSid `333` | 코드에는 파라미터만 확인됨: `maestro_name`, `available_date`. 실제 제목/본문은 NCloud 콘솔 템플릿에 저장된 것으로 보인다. |
## 템플릿/테스트 흔적만 확인된 메일
| 메일 | 위치 | 상태 |
|---|---|---|
| 등록 입금 안내 | `sendMailRegisterBankInfo`, `mail_contents.php``registrationBankInfo` | 제목/본문은 있으나 주요 등록 플로우에서는 호출되지 않고 관리자 테스트 화면에서 호출된다. |
| 클로즈베타 등록 입금 안내 | `mail_contents.php``closedBetaRegistrationBankInfo` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
| 클로즈베타 등록 완료 | `mail_contents.php``closedBetaRegistrationDone` | 템플릿만 확인됨. 현재 호출부는 확인되지 않았다. |
| 클로즈베타 유료 결제 감사/기간 +1개월 | `sendMailToClosedBetaPaidMestroEmail`, `src/php-cli/batch/old/send_mail_to_closed_beta_paid_maestro.php` | old 배치에서 호출된다. |
## 구현 시 참고
- 연장/업그레이드 승인 메일은 기존 PHP 코드에서 DB 변경 후 발송한다.
- 신규 구현에서는 DB 트랜잭션 커밋 이후 메일 발송을 분리하는 것이 안전하다.
- 메일 발송 실패가 신청 승인/상태 변경을 롤백해야 하는지는 별도 정책 결정이 필요하다.
- 계좌 정보, 발송자/BCC, SMTP/API 인증 정보는 코드에 하드코딩하지 말고 환경변수로 관리해야 한다.
+146 -7
View File
@@ -37,6 +37,7 @@
| ORM | Prisma (기존 DB introspection 방식으로 활용) |
| 유효성 검증 | Zod |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 이메일 발송 | Nodemailer (기존 `mouse-typing` SMTP 설정을 환경변수로 이관) |
| 환경변수 관리 | `.env.local` / `.env.production` |
### Prisma vs Drizzle ORM
@@ -170,7 +171,9 @@ chocoadmin/
│ ├── auth/[...nextauth]/
│ ├── maestros/
│ │ ├── route.ts # GET: 목록
│ │ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
│ │ └── [id]/
│ │ ├── route.ts # GET: 상세, PATCH: 정보 수정
│ │ └── students/route.ts # GET: 학생 목록
│ ├── extension-requests/
│ │ └── [id]/route.ts # PATCH: 승인/취소
│ └── upgrade-requests/
@@ -184,6 +187,7 @@ chocoadmin/
├── lib/
│ ├── db.ts # Prisma Client 싱글톤
│ ├── auth.ts # NextAuth 설정
│ ├── mail.ts # chocomae SMTP 메일 발송 모듈
│ ├── validators/ # Zod 스키마
│ └── constants.ts # 상태값 상수
├── types/
@@ -222,7 +226,17 @@ chocoadmin/
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`)
- 기본 계정 정보 (이름, 이메일, 계정 유형, 상태, 만료일, 약관 동의일)
- 플레이어(학생) 목록 요약
- 기본 계정 정보 수정 및 저장
- 이름: 저장 시 다른 마에스트로 계정에서 이미 사용 중인지 검사하고, 중복이면 저장 실패 처리
- 이메일
- 상태: 콤보박스 (`maestro.ActivateStatus`)
- 계정유형: 콤보박스 (`maestro.AccountType`)
- 사용 가능일 (`maestro.AvailableActivateDateTime`)
- 입장코드 수정: 콤보박스 기반 입력/선택 UI
- 정보 수정 및 저장 성공 시 `maestro_log`에 처리 이력 기록
- 플레이어(학생) 전체 목록
- 서버 사이드 페이지네이션
- 이름 검색
- 연장 신청 이력
- 업그레이드 신청 이력
- 관리자 처리 로그 (`maestro_log`)
@@ -240,6 +254,12 @@ chocoadmin/
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` 인지 확인
@@ -261,6 +281,12 @@ chocoadmin/
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` 인지 확인
@@ -280,6 +306,7 @@ chocoadmin/
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
8. SMTP/API 인증 정보, 발송자 주소, BCC 주소는 환경변수 또는 배포 시크릿으로 관리하고 코드에 하드코딩하지 않는다.
---
@@ -354,10 +381,122 @@ chocoadmin/
### Phase 8. 검증 및 안정화
- [ ] 승인/취소/거절 트랜잭션 시나리오 테스트
- [ ] 중복 처리 방지 (이미 처리된 건 재처리 시 에러 확인)
- [ ] 비인증 접근 차단 확인
- [ ] Docker 재시작 후 정상 동작 확인
- [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] local에서 query문과 결과 전체가 출력되는지 확인
- [ ] stage에서 query문과 결과 전체가 출력되는지 확인
- [ ] 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` 로그 확인
---
@@ -372,8 +511,8 @@ chocoadmin/
- Docker 기반 NAS 배포
**MVP 이후 확장 후보:**
- 마에스트로 상태 직접 변경
- 학생 목록 상세 관리
- 이메일 발송 이력/재발송 관리
- 데이터 내보내기 (CSV)
- 처리 통계 대시보드
- 감사 로그 전용 화면
+54
View File
@@ -0,0 +1,54 @@
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,
});
throw error;
}
};
}
+210 -2
View File
@@ -1,11 +1,202 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { format } from "sql-formatter";
import { PrismaClient } from "@/lib/generated/prisma/client";
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
import { logger } from "@/lib/logger";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
// ── 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 level = isSlow ? "warn" : "debug";
if (!logger.isEnabled(level)) return;
const isSensitive = e.query.includes("PASSWORD(");
const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params);
const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
const ts = new Date().toISOString();
const tag = level.toUpperCase().padEnd(5);
const slowLabel = isSlow ? " [SLOW]" : "";
const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
const indented = formatted
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
const out = `${header}\n${indented}`;
if (isSlow) {
console.error(out);
} else {
console.log(out);
}
}
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
const COL_MAX_WIDTH = 40;
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) => formatValue(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,
formatValue(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 = new Date().toISOString();
const body = buildResultLog(model, operation, result);
console.log(`[${ts}] [DEBUG] [db:result] ${body}`);
}
// ── PrismaClient 생성 ─────────────────────────────────────────────
function createPrismaClient() {
const databaseUrl = process.env.DATABASE_URL;
@@ -14,8 +205,25 @@ function createPrismaClient() {
}
const adapter = new PrismaMariaDb(databaseUrl);
const baseClient = new PrismaClient({
adapter,
log: [{ emit: "event", level: "query" }],
});
return new PrismaClient({ adapter });
baseClient.$on("query", onQuery);
const extendedClient = baseClient.$extends({
query: {
$allOperations: async ({ model, operation, args, query }) => {
const result = await query(args);
logResult(model, operation, result);
return result;
},
},
});
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
return extendedClient as unknown as PrismaClient;
}
export const db = globalForPrisma.prisma ?? createPrismaClient();
+9
View File
@@ -0,0 +1,9 @@
export class ApiError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ApiError";
}
}
+18 -13
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
@@ -54,19 +56,10 @@ export type ExtensionRequestListResult = {
status?: number | "all";
};
export class ExtensionRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ExtensionRequestActionError";
}
}
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;
@@ -96,6 +89,12 @@ export async function getExtensionRequests(
}),
]);
logger.info("extension-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return {
items: requests.map(toExtensionRequestListItem),
page: params.page,
@@ -109,7 +108,11 @@ export async function getExtensionRequests(
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }> {
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
}> {
return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID);
const availableActivateDateTime = calculateExtendedAvailableDate(
@@ -148,6 +151,8 @@ export async function approveExtensionRequest(
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
};
});
}
@@ -194,11 +199,11 @@ async function getActionTarget(
});
if (!request) {
throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404);
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409);
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
}
return request;
+50
View File
@@ -0,0 +1,50 @@
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()];
}
function write(
level: Level,
ctx: string,
message: string,
data?: Record<string, unknown>
): void {
if (!shouldLog(level)) return;
const ts = new Date().toISOString();
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),
};
+273
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, ACCOUNT_TYPES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
const pageSizeOptions = [10, 20, 50, 100] as const;
@@ -101,9 +104,266 @@ export type MaestroDetail = {
}>;
};
const paidAccountTypeValues = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
const activateStatusValues = [
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) => (paidAccountTypeValues 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())) {
const prevMin = Math.floor(
maestro.AvailableActivateDateTime.getTime() / 60000
);
const newMin = Math.floor(newDate.getTime() / 60000);
if (prevMin !== newMin) {
updates.AvailableActivateDateTime = newDate;
const prevStr = maestro.AvailableActivateDateTime.toISOString().slice(
0,
10
);
const newStr = newDate.toISOString().slice(0, 10);
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 };
}
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);
@@ -131,6 +391,12 @@ export async function getMaestros(
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,
@@ -147,6 +413,7 @@ export async function getMaestros(
export async function getMaestroDetail(
maestroID: number
): Promise<MaestroDetail | null> {
const t0 = Date.now();
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: {
@@ -164,6 +431,7 @@ export async function getMaestroDetail(
});
if (!maestro) {
logger.warn("maestros/[id]", "not found", { id: maestroID });
return null;
}
@@ -229,6 +497,11 @@ export async function getMaestroDetail(
}),
]);
logger.info("maestros/[id]", "fetched", {
id: maestroID,
duration: Date.now() - t0,
});
return {
maestro: {
...toMaestroListItem(maestro),
+28
View File
@@ -0,0 +1,28 @@
export type MailConfig = {
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassword: string;
fromName: string;
fromAddress: string;
bccAddress: string;
sendEnabled: boolean;
/** 설정 시 모든 수신인을 이 주소로 고정 (local/stage 테스트용) */
overrideTo: string | undefined;
};
export function getMailConfig(): MailConfig {
return {
smtpHost: process.env.MAIL_SMTP_HOST ?? "smtp.gmail.com",
smtpPort: Number(process.env.MAIL_SMTP_PORT ?? "465"),
smtpSecure: process.env.MAIL_SMTP_SECURE !== "false",
smtpUser: process.env.MAIL_SMTP_USER ?? "",
smtpPassword: process.env.MAIL_SMTP_PASSWORD ?? "",
fromName: process.env.MAIL_FROM_NAME ?? "초코마에",
fromAddress: process.env.MAIL_FROM_ADDRESS ?? "",
bccAddress: process.env.MAIL_BCC_ADDRESS ?? "",
sendEnabled: process.env.MAIL_SEND_ENABLED === "true",
overrideTo: process.env.MAIL_OVERRIDE_TO || undefined,
};
}
+158
View File
@@ -0,0 +1,158 @@
import nodemailer from "nodemailer";
import { ACTIVATE_STATUSES } from "@/lib/constants";
import { logger } from "@/lib/logger";
import { getMailConfig } from "@/lib/mail-config";
import {
getAccountTypePlayerCount,
getAccountTypePrice,
} from "@/lib/utils";
const CTX = "mail";
function createTransporter() {
const cfg = getMailConfig();
return nodemailer.createTransport({
host: cfg.smtpHost,
port: cfg.smtpPort,
secure: cfg.smtpSecure,
auth: {
user: cfg.smtpUser,
pass: cfg.smtpPassword,
},
});
}
type SendMailOptions = {
to: string;
subject: string;
html: string;
};
const isDetailedLog = () => process.env.APP_ENV !== "production";
async function sendMail(options: SendMailOptions): Promise<boolean> {
const cfg = getMailConfig();
const detailed = isDetailedLog();
if (!cfg.sendEnabled) {
logger.info(CTX, "send skipped (MAIL_SEND_ENABLED=false)", {
to: cfg.overrideTo ?? options.to,
subject: options.subject,
});
return false;
}
const to = cfg.overrideTo ?? options.to;
const bcc = cfg.bccAddress || undefined;
if (detailed) {
logger.debug(CTX, "sending", {
to,
...(bcc && { bcc }),
subject: options.subject,
smtp: `${cfg.smtpUser}@${cfg.smtpHost}:${cfg.smtpPort}`,
...(cfg.overrideTo && { overrideTo: cfg.overrideTo }),
});
}
const t0 = Date.now();
try {
const transporter = createTransporter();
const info = await transporter.sendMail({
from: `"${cfg.fromName}" <${cfg.fromAddress}>`,
replyTo: cfg.fromAddress,
to,
bcc,
subject: options.subject,
html: options.html,
});
const duration = Date.now() - t0;
if (detailed) {
logger.info(CTX, "send ok", {
to,
subject: options.subject,
messageId: info.messageId,
duration,
});
} else {
logger.info(CTX, "send ok", { duration });
}
return true;
} catch (err) {
const duration = Date.now() - t0;
if (detailed) {
logger.error(CTX, "send failed", {
to,
subject: options.subject,
duration,
error: err instanceof Error ? err.message : String(err),
...(err instanceof Error && err.stack && { stack: err.stack }),
});
} else {
logger.error(CTX, "send failed", { duration });
}
return false;
}
}
export async function sendExtensionDoneEmail(
maestroName: string,
maestroEmail: string,
newAvailableDate: string
): Promise<boolean> {
const subject = `[초코마에] ${maestroName} - 마에스트로 계정 유효기간 연장 완료`;
const html = `안녕하세요.<br/>
[초코마에] 마에스트로 계정의 유효기간 연장을 신청해 주셔서 감사합니다.<br/>
<br/>
${maestroName} - 마에스트로 계정의 유효기간이 +1년 연장되었습니다.<br/>
* 새로운 유효기간 : ${newAvailableDate}<br/>
<br/>
* 상위 요금제로 업그레이드 하시면, 유효기간이 업그레이드한 날짜로부터 1년으로 갱신됩니다.<br/>
* 유효기간이 끝나면, 비활성화된 계정과 그 안에 있는 모든 정보가 6개월 후에 자동 삭제됩니다.<br/>
<br/>
감사합니다.`;
return sendMail({ to: maestroEmail, subject, html });
}
export async function sendUpgradeDoneEmail(
maestroName: string,
maestroEmail: string,
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number,
newAvailableDate: string
): Promise<boolean> {
const subject = `[초코마에] ${maestroName} - 마에스트로 계정의 요금제 업그레이드 완료 알림`;
const prevStudentCount = getAccountTypePlayerCount(registeredAccountType);
const upgradeStudentCount = getAccountTypePlayerCount(requestedAccountType);
const upgradePrice = getAccountTypePrice(requestedAccountType).toLocaleString("ko-KR");
let upgradeFromText: string;
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
upgradeFromText = `${prevStudentCount}명 (0원 : 무료 체험 기간)`;
} else {
const prevPrice = getAccountTypePrice(registeredAccountType).toLocaleString("ko-KR");
upgradeFromText = `${prevStudentCount}명 (${prevPrice}원)`;
}
const html = `안녕하세요.<br/>
[초코마에] 마에스트로 계정 요금제 업그레이드를 신청해주셔서 감사합니다.<br/>
<br/>
${maestroName} - 마에스트로 계정의 요금제가 [ ${upgradeFromText} ] 에서 [ ${upgradeStudentCount}명 (${upgradePrice}원) ] 으로 업그레이드 되었습니다.<br/>
<br/>
* 마에스트로 계정 유효기간이 오늘부터 1년으로 설정되었습니다.<br/>
<br/>
<br/>
등록하신 마에스트로 계정 정보는 마에스트로 로그인 후, [계정 정보 수정] 화면에서 변경이 가능합니다.<br/>
(변경 가능 정보 : 마에스트로 아이디, 이메일, 암호)<br/>
<br/>
감사합니다.`;
return sendMail({ to: maestroEmail, subject, html });
}
+27 -14
View File
@@ -1,10 +1,13 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
calculateUpgradePrice,
formatDate,
getAccountTypeLabel,
getAccountTypePrice,
getTrialAccountTypeLabel,
@@ -57,19 +60,10 @@ export type UpgradeRequestListResult = {
status?: number | "all";
};
export class UpgradeRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "UpgradeRequestActionError";
}
}
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;
@@ -99,6 +93,12 @@ export async function getUpgradeRequests(
}),
]);
logger.info("upgrade-requests", "list fetched", {
totalCount,
page: params.page,
duration: Date.now() - t0,
});
return {
items: requests.map(toUpgradeRequestListItem),
page: params.page,
@@ -112,7 +112,14 @@ export async function getUpgradeRequests(
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{ availableActivateDateTime: string }> {
): Promise<{
availableActivateDateTime: string;
maestroName: string;
maestroEmail: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
}> {
return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID);
const availableActivateDateTime = calculateUpgradeAvailableDate();
@@ -151,6 +158,11 @@ export async function approveUpgradeRequest(
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
maestroName: request.maestro.Name.trim(),
maestroEmail: request.maestro.Email.trim(),
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
};
});
}
@@ -194,17 +206,18 @@ async function getActionTarget(
) {
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
include: { maestro: { select: { Name: true, Email: true } } },
});
if (!request) {
throw new UpgradeRequestActionError(
throw new ApiError(
"업그레이드 신청을 찾을 수 없습니다.",
404
);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new UpgradeRequestActionError(
throw new ApiError(
"이미 처리된 업그레이드 신청입니다.",
409
);
@@ -276,7 +289,7 @@ function toUpgradeRequestListItem(request: {
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
requestedDateTime: formatDate(request.RequestedDateTime),
availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status,
+16
View File
@@ -104,6 +104,22 @@ export function calculateExtendedAvailableDate(
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;
+3
View File
@@ -23,9 +23,11 @@
"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"
@@ -33,6 +35,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
+85 -4
View File
@@ -37,7 +37,10 @@ importers:
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
version: 5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4)
nodemailer:
specifier: ^9.0.1
version: 9.0.1
react:
specifier: 19.2.4
version: 19.2.4
@@ -47,6 +50,9 @@ importers:
shadcn:
specifier: ^4.11.0
version: 4.11.0(typescript@5.9.3)
sql-formatter:
specifier: ^15.8.2
version: 15.8.2
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -63,6 +69,9 @@ importers:
'@types/node':
specifier: ^20
version: 20.19.42
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/react':
specifier: ^19
version: 19.2.17
@@ -1102,6 +1111,9 @@ packages:
'@types/node@24.13.1':
resolution: {integrity: sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1528,6 +1540,9 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1679,6 +1694,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -2650,6 +2668,9 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2674,6 +2695,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@@ -2732,6 +2757,10 @@ packages:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
nodemailer@9.0.1:
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
engines: {node: '>=6.0.0'}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -2968,6 +2997,13 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -3033,6 +3069,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@@ -3155,6 +3195,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
sql-formatter@15.8.2:
resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==}
hasBin: true
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
@@ -3479,13 +3523,15 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@auth/core@0.41.2':
'@auth/core@0.41.2(nodemailer@9.0.1)':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 9.0.1
'@babel/code-frame@7.29.7':
dependencies:
@@ -4376,6 +4422,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 20.19.42
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -4803,6 +4853,8 @@ snapshots:
commander@14.0.3: {}
commander@2.20.3: {}
concat-map@0.0.1: {}
confbox@0.2.4: {}
@@ -4914,6 +4966,8 @@ snapshots:
diff@8.0.4: {}
discontinuous-range@1.0.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -6019,6 +6073,8 @@ snapshots:
minimist@1.2.8: {}
moo@0.5.3: {}
ms@2.1.3: {}
mysql2@3.15.3:
@@ -6043,13 +6099,22 @@ snapshots:
natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
negotiator@1.0.0: {}
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
next-auth@5.0.0-beta.31(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@9.0.1)(react@19.2.4):
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.2(nodemailer@9.0.1)
next: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
optionalDependencies:
nodemailer: 9.0.1
next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
@@ -6092,6 +6157,8 @@ snapshots:
node-releases@2.0.47: {}
nodemailer@9.0.1: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -6340,6 +6407,13 @@ snapshots:
queue-microtask@1.2.3: {}
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
range-parser@1.2.1: {}
raw-body@3.0.2:
@@ -6417,6 +6491,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
ret@0.1.15: {}
retry@0.12.0: {}
reusify@1.1.0: {}
@@ -6632,6 +6708,11 @@ snapshots:
source-map@0.6.1: {}
sql-formatter@15.8.2:
dependencies:
argparse: 2.0.1
nearley: 2.20.1
sqlstring@2.3.3: {}
stable-hash@0.0.5: {}
+1 -1
View File
@@ -33,5 +33,5 @@ export async function proxy(request: NextRequest) {
}
export const config = {
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
matcher: ["/((?!api/|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};