diff --git a/.env.example b/.env.example index 446986d..8bc25a4 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ 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 # Production example (.env.production) # DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae" @@ -11,6 +14,8 @@ 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 # Stage example (.env.stage) # DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae" @@ -18,3 +23,5 @@ 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 diff --git a/AGENTS.md b/AGENTS.md index 35a99f5..a7b590d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,13 +32,17 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone |---|---| | `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) | | `proxy.ts` | Middleware — redirects unauthenticated users to /login | -| `lib/db.ts` | Prisma Client singleton | +| `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 @@ -57,11 +61,11 @@ Always record a log entry after a successful DB change. Use these `Type` values: ## Implemented phases (do not re-implement) -- Phase 0–8 are complete: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. +- Phase 0–8: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. +- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification. ## Planned phases (not yet implemented) -- **Phase 9** — Structured logging (`lib/logger.ts`, correlation IDs, Prisma query logging) - **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`) diff --git a/README.md b/README.md index 1b45307..dfc9df7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스 - 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`) - 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`) - 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사) +- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹 ## 개발 환경 설정 @@ -73,8 +74,9 @@ pnpm dev | `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) | | `AUTH_URL` | ✅ | 서비스 기본 URL | | `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) | -| `APP_ENV` | — | `local` \| `stage` \| `production` (Phase 9) | -| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` (Phase 9) | +| `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) | @@ -110,7 +112,10 @@ chocoadmin/ │ ├── forms/ │ └── ui/ # shadcn/ui 컴포넌트 ├── lib/ -│ ├── db.ts # Prisma Client 싱글톤 +│ ├── 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 @@ -131,3 +136,4 @@ chocoadmin/ - [`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 로그 운영 절차 diff --git a/app/api/extension-requests/[id]/route.ts b/app/api/extension-requests/[id]/route.ts index fa232f6..8283b66 100644 --- a/app/api/extension-requests/[id]/route.ts +++ b/app/api/extension-requests/[id]/route.ts @@ -1,67 +1,51 @@ 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"; + +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); + logger.info(CTX, "approved", { + id: maestroExtensionID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true, ...result }); } 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; } -} +); diff --git a/app/api/extension-requests/route.ts b/app/api/extension-requests/route.ts index 302e77f..2d94ce5 100644 --- a/app/api/extension-requests/route.ts +++ b/app/api/extension-requests/route.ts @@ -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); -} +}); diff --git a/app/api/maestros/[id]/route.ts b/app/api/maestros/[id]/route.ts index 9d3cf1a..ff2250a 100644 --- a/app/api/maestros/[id]/route.ts +++ b/app/api/maestros/[id]/route.ts @@ -1,39 +1,25 @@ import { NextResponse } from "next/server"; -import { auth } from "@/auth"; import { getMaestroDetail } from "@/lib/maestros"; +import { ApiError } from "@/lib/errors"; +import { withApiHandler } from "@/lib/api-handler"; -type MaestroDetailRouteContext = { - params: Promise<{ - id: string; - }>; -}; +export const GET = withApiHandler<{ id: string }>( + "maestros/[id]", + async (_request, { params }) => { + const { id } = await params; + const maestroID = Number(id); -export async function GET( - _request: Request, - { params }: MaestroDetailRouteContext -) { - const session = await auth(); + if (!Number.isInteger(maestroID) || maestroID <= 0) { + throw new ApiError("Invalid maestro id", 400); + } - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + 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); - - if (!Number.isInteger(maestroID) || maestroID <= 0) { - return NextResponse.json( - { message: "Invalid maestro id" }, - { status: 400 } - ); - } - - const result = await getMaestroDetail(maestroID); - - if (!result) { - return NextResponse.json({ message: "Not found" }, { status: 404 }); - } - - return NextResponse.json(result); -} +); diff --git a/app/api/maestros/route.ts b/app/api/maestros/route.ts index 3e40dc9..5070fc6 100644 --- a/app/api/maestros/route.ts +++ b/app/api/maestros/route.ts @@ -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); -} +}); diff --git a/app/api/upgrade-requests/[id]/route.ts b/app/api/upgrade-requests/[id]/route.ts index 71e0164..75a66f1 100644 --- a/app/api/upgrade-requests/[id]/route.ts +++ b/app/api/upgrade-requests/[id]/route.ts @@ -1,67 +1,51 @@ 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"; + +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); + logger.info(CTX, "approved", { + id: maestroUpgradeID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true, ...result }); } 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; } -} +); diff --git a/app/api/upgrade-requests/route.ts b/app/api/upgrade-requests/route.ts index 3861d8c..63a7c24 100644 --- a/app/api/upgrade-requests/route.ts +++ b/app/api/upgrade-requests/route.ts @@ -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); -} +}); diff --git a/auth.ts b/auth.ts index b6f27cd..66a6cb6 100644 --- a/auth.ts +++ b/auth.ts @@ -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, diff --git a/docs/phase/phase9.md b/docs/phase/phase9.md new file mode 100644 index 0000000..639100a --- /dev/null +++ b/docs/phase/phase9.md @@ -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) | diff --git a/docs/plan/project-plan.md b/docs/plan/project-plan.md index a270e2e..f41bb7e 100644 --- a/docs/plan/project-plan.md +++ b/docs/plan/project-plan.md @@ -390,43 +390,43 @@ chocoadmin/ > local/stage에서는 개발과 디버깅에 충분한 상세 로그를 남기고, production에서는 운영에 필요한 수준으로 로그 양과 민감정보 노출을 제한한다. -- [ ] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`) - - [ ] 환경 구분: `local`, `stage`, `production` - - [ ] 로그 레벨 구분: `debug`, `info`, `warn`, `error` - - [ ] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조 - - [ ] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드 -- [ ] 서버 실행 로그 유틸 구현 - - [ ] 공통 logger 모듈 추가 (`lib/logger.ts`) - - [ ] request 단위 correlation id 생성 및 로그에 포함 - - [ ] API route 시작/종료, 처리 시간, status code, action 결과 기록 - - [ ] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 -- [ ] DB query 로그 구현 - - [ ] Prisma query event 또는 Prisma Client 설정 기반 query 로깅 적용 - - [ ] local/stage: query문, params, duration, 결과 row/count 또는 요약 결과를 사람이 보기 쉬운 format으로 출력 - - [ ] production: query문 전체/결과 전체 출력 금지, duration, table/action 추정값, affected count, slow query 중심으로 출력 - - [ ] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`) - - [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 -- [ ] 환경변수 추가 - - [ ] `APP_ENV=local|stage|production` - - [ ] `LOG_LEVEL=debug|info|warn|error` - - [ ] `DB_QUERY_LOG=off|summary|full` - - [ ] `DB_SLOW_QUERY_MS` - - [ ] `DB_LOG_RESULT_LIMIT` -- [ ] 주요 API에 로그 적용 - - [ ] 로그인 성공/실패 로그 - - [ ] 마에스트로 목록/상세 조회 로그 - - [ ] 연장 신청 조회/승인/취소 로그 - - [ ] 업그레이드 신청 조회/승인/거절 로그 - - [ ] 중복 처리 방지로 `409` 반환 시 warning 로그 -- [ ] Docker/NAS 로그 운영 절차 문서화 - - [ ] `docker compose logs -f` 확인 방법 - - [ ] stage와 production의 권장 `.env` 로그 설정 예시 - - [ ] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차 +- [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와 애플리케이션 로그를 같이 확인하는 절차 - [ ] 검증 - - [ ] local에서 query문과 결과 전체가 출력되는지 확인 + - [x] local에서 query문과 결과 전체가 출력되는지 확인 - [ ] stage에서 query문과 결과 전체가 출력되는지 확인 - [ ] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인 - - [ ] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 + - [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략) ### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화 diff --git a/lib/api-handler.ts b/lib/api-handler.ts new file mode 100644 index 0000000..edc0a42 --- /dev/null +++ b/lib/api-handler.ts @@ -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

= { + params: Promise

; + t0: number; +}; + +export function withApiHandler

>( + ctx: string, + handler: (req: Request, ctx: ApiHandlerContext

) => Promise +) { + return async ( + request: Request, + routeContext?: { params: Promise

} + ): Promise => { + const t0 = Date.now(); + + const session = await auth(); + if (!session) { + logger.warn(ctx, "unauthorized"); + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const handlerCtx: ApiHandlerContext

= { + params: routeContext?.params ?? (Promise.resolve({}) as Promise

), + 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; + } + }; +} diff --git a/lib/db.ts b/lib/db.ts index 4837a07..d8a72d9 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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[]; + 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).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(); diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..fe82b42 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,9 @@ +export class ApiError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + this.name = "ApiError"; + } +} diff --git a/lib/extension-requests.ts b/lib/extension-requests.ts index 1b04b0d..1734005 100644 --- a/lib/extension-requests.ts +++ b/lib/extension-requests.ts @@ -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 { + 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, @@ -194,11 +193,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; diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..45fab84 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,50 @@ +type Level = "debug" | "info" | "warn" | "error"; + +const PRIORITY: Record = { + 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 +): 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) => + write("debug", ctx, message, data), + info: (ctx: string, message: string, data?: Record) => + write("info", ctx, message, data), + warn: (ctx: string, message: string, data?: Record) => + write("warn", ctx, message, data), + error: (ctx: string, message: string, data?: Record) => + write("error", ctx, message, data), + isEnabled: (level: Level): boolean => shouldLog(level), +}; diff --git a/lib/maestros.ts b/lib/maestros.ts index e5dfd34..1765fb0 100644 --- a/lib/maestros.ts +++ b/lib/maestros.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { db } from "@/lib/db"; +import { logger } from "@/lib/logger"; import type { Prisma } from "@/lib/generated/prisma/client"; const pageSizeOptions = [10, 20, 50, 100] as const; @@ -104,6 +105,7 @@ export type MaestroDetail = { export async function getMaestros( rawSearchParams: RawSearchParams = {} ): Promise { + const t0 = Date.now(); const params = parseMaestroListSearchParams(rawSearchParams); const where = buildMaestroWhere(params); const orderBy = buildMaestroOrderBy(params.sort); @@ -131,6 +133,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 +155,7 @@ export async function getMaestros( export async function getMaestroDetail( maestroID: number ): Promise { + const t0 = Date.now(); const maestro = await db.maestro.findUnique({ where: { MaestroID: maestroID }, select: { @@ -164,6 +173,7 @@ export async function getMaestroDetail( }); if (!maestro) { + logger.warn("maestros/[id]", "not found", { id: maestroID }); return null; } @@ -229,6 +239,11 @@ export async function getMaestroDetail( }), ]); + logger.info("maestros/[id]", "fetched", { + id: maestroID, + duration: Date.now() - t0, + }); + return { maestro: { ...toMaestroListItem(maestro), diff --git a/lib/upgrade-requests.ts b/lib/upgrade-requests.ts index 4209468..256a871 100644 --- a/lib/upgrade-requests.ts +++ b/lib/upgrade-requests.ts @@ -1,6 +1,8 @@ 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 { @@ -58,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 { + const t0 = Date.now(); const params = parseUpgradeRequestSearchParams(rawSearchParams); const where = buildUpgradeRequestWhere(params); const skip = (params.page - 1) * params.pageSize; @@ -100,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, @@ -198,14 +197,14 @@ async function getActionTarget( }); if (!request) { - throw new UpgradeRequestActionError( + throw new ApiError( "업그레이드 신청을 찾을 수 없습니다.", 404 ); } if (request.Status !== REQUEST_STATUSES.REQUESTED) { - throw new UpgradeRequestActionError( + throw new ApiError( "이미 처리된 업그레이드 신청입니다.", 409 ); diff --git a/package.json b/package.json index 51ccac8..eab2a71 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46ca81d..67c8bf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,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 @@ -1528,6 +1531,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 +1685,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 +2659,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 +2686,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'} @@ -2968,6 +2984,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 +3056,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 +3182,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'} @@ -4803,6 +4834,8 @@ snapshots: commander@14.0.3: {} + commander@2.20.3: {} + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -4914,6 +4947,8 @@ snapshots: diff@8.0.4: {} + discontinuous-range@1.0.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -6019,6 +6054,8 @@ snapshots: minimist@1.2.8: {} + moo@0.5.3: {} + ms@2.1.3: {} mysql2@3.15.3: @@ -6043,6 +6080,13 @@ 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): @@ -6340,6 +6384,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 +6468,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 +6685,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: {}