Compare commits

...

9 Commits

64 changed files with 13386 additions and 22 deletions
+21
View File
@@ -0,0 +1,21 @@
.env
.env.*
!.env.example
.git
.gitignore
.next
.pnpm-store
.turbo
.DS_Store
.idea
.claude
coverage
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
Dockerfile
docker-compose.yml
README.md
docs
+17
View File
@@ -0,0 +1,17 @@
# Local development
DATABASE_URL="mysql://USER:PASSWORD@127.0.0.1:3306/chocomae"
AUTH_SECRET="replace-with-local-secret"
NEXTAUTH_SECRET="replace-with-local-secret"
NEXTAUTH_URL="http://localhost:3001"
# Production example (.env.production)
# DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae"
# AUTH_SECRET="replace-with-production-secret"
# NEXTAUTH_SECRET="replace-with-production-secret"
# NEXTAUTH_URL="http://NAS_HOST_OR_DOMAIN:3001"
# Stage example (.env.stage)
# DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae"
# AUTH_SECRET="replace-with-stage-secret"
# NEXTAUTH_SECRET="replace-with-stage-secret"
# NEXTAUTH_URL="http://STAGE_HOST_OR_DOMAIN:3001"
+42 -22
View File
@@ -1,25 +1,45 @@
### macOS
# Finder metadata
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
# Thumbnails
._*
# Custom folder icons
Icon
# Volume root files
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
### Local development tools
*.pem
.idea/
# Claude Code local settings
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/lib/generated/prisma
+8
View File
@@ -0,0 +1,8 @@
.next
node_modules
pnpm-lock.yaml
lib/generated/prisma
docs
AGENTS.md
CLAUDE.md
README.md
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
+5
View File
@@ -0,0 +1,5 @@
<!-- 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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+39
View File
@@ -0,0 +1,39 @@
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="mysql://build:build@127.0.0.1:3306/chocomae"
ENV AUTH_SECRET="build-time-placeholder"
ENV NEXTAUTH_SECRET="build-time-placeholder"
ENV NEXTAUTH_URL="http://localhost:3000"
RUN pnpm prisma generate
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
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).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
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.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [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.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
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.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+238
View File
@@ -0,0 +1,238 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { ExtensionRequestsTable } from "@/components/data-table/extension-requests-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
import {
getExtensionRequests,
type RawSearchParams,
} from "@/lib/extension-requests";
import { cn } from "@/lib/utils";
type ExtensionRequestsPageProps = {
searchParams: Promise<RawSearchParams>;
};
const statusOptions = [
REQUEST_STATUSES.REQUESTED,
REQUEST_STATUSES.CLOSED,
REQUEST_STATUSES.APPLIED,
] as const;
export default async function ExtensionRequestsPage({
searchParams,
}: ExtensionRequestsPageProps) {
const params = await searchParams;
const result = await getExtensionRequests(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-normal"> </h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="마에스트로명, ID, 신청ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="status"
value={result.status ?? "all"}
>
<option value="all"></option>
{statusOptions.map((status) => (
<option key={status} value={status}>
{REQUEST_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href="/extension-requests"
>
</Link>
</div>
</form>
<ExtensionRequestsTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{result.pageSize}
</p>
<nav
aria-label="연장 신청 목록 페이지"
className="flex flex-wrap items-center gap-1.5"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/extension-requests?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import {
ArrowUpCircle,
ClipboardList,
GraduationCap,
LayoutDashboard,
LogOut,
} from "lucide-react";
import { auth, signOut } from "@/auth";
import { Button } from "@/components/ui/button";
const navItems = [
{ href: "/", label: "대시보드", icon: LayoutDashboard },
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
];
export default async function AdminLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div className="min-h-screen bg-muted/40">
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r bg-background md:block">
<div className="flex h-16 items-center border-b px-5">
<Link className="text-lg font-semibold" href="/">
chocoadmin
</Link>
</div>
<nav className="space-y-1 p-3">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
href={item.href}
key={item.href}
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</Link>
);
})}
</nav>
</aside>
<div className="md:pl-64">
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
<div>
<p className="text-sm font-medium">{session.user.name}</p>
<p className="text-xs text-muted-foreground">
ID {session.user.adminID}
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
>
<Button size="sm" type="submit" variant="outline">
<LogOut className="size-4" aria-hidden="true" />
</Button>
</form>
</header>
<main className="p-5">{children}</main>
</div>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { getMaestroDetail } from "@/lib/maestros";
import {
formatDate,
formatDateTime,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
} from "@/lib/utils";
type MaestroDetailPageProps = {
params: Promise<{
maestroId: string;
}>;
};
export default async function MaestroDetailPage({
params,
}: MaestroDetailPageProps) {
const { maestroId } = await params;
const maestroID = Number(maestroId);
if (!Number.isInteger(maestroID) || maestroID <= 0) {
notFound();
}
const detail = await getMaestroDetail(maestroID);
if (!detail) {
notFound();
}
const { maestro } = detail;
return (
<section className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<Link
className={buttonVariants({
variant: "ghost",
size: "sm",
className: "mb-2 -ml-2",
})}
href="/maestros"
>
<ArrowLeft className="size-4" aria-hidden="true" />
</Link>
<h1 className="text-2xl font-semibold tracking-normal">
{maestro.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
MaestroID {maestro.maestroID} · {maestro.email}
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-4">
<SummaryCard
label="상태"
value={getActivateStatusLabel(maestro.activateStatus)}
/>
<SummaryCard
label="계정 유형"
value={getAccountTypeLabel(maestro.accountType)}
/>
<SummaryCard
label="학생 수"
value={detail.counts.players.toLocaleString()}
/>
<SummaryCard
label="사용 가능일"
value={formatDate(maestro.availableActivateDateTime)}
/>
</div>
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
<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 ?? "-",
])}
/>
</section>
<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>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.extensionRequests.toLocaleString()}
20
</p>
<SimpleTable
emptyText="연장 신청 이력이 없습니다."
headers={["신청ID", "계정 유형", "신청일시", "상태"]}
rows={detail.extensionRequests.map((request) => [
request.maestroExtensionID,
getAccountTypeLabel(request.accountType),
formatDateTime(request.requestedDateTime),
getRequestStatusLabel(request.status),
])}
/>
</section>
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.upgradeRequests.toLocaleString()} 20
</p>
<SimpleTable
emptyText="업그레이드 신청 이력이 없습니다."
headers={["신청ID", "현재", "요청", "신청일시", "상태"]}
rows={detail.upgradeRequests.map((request) => [
request.maestroUpgradeID,
getAccountTypeLabel(request.registeredAccountType),
getAccountTypeLabel(request.requestedAccountType),
formatDateTime(request.requestedDateTime),
getRequestStatusLabel(request.status),
])}
/>
</section>
</div>
<section className="rounded-lg border bg-background p-5">
<h2 className="text-base font-semibold"> </h2>
<p className="mt-1 text-sm text-muted-foreground">
{detail.counts.logs.toLocaleString()} 30
</p>
<SimpleTable
emptyText="처리 로그가 없습니다."
headers={["LogID", "유형", "처리일시", "내용"]}
rows={detail.logs.map((log) => [
log.maestroLogID,
log.type,
formatDateTime(log.logDateTime),
log.remark,
])}
/>
</section>
</section>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<article className="rounded-lg border bg-background p-4">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-2 text-lg font-semibold">{value}</p>
</article>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="mt-1 break-words font-medium">{value}</dd>
</div>
);
}
function SimpleTable({
headers,
rows,
emptyText,
}: {
headers: string[];
rows: Array<Array<string | number>>;
emptyText: string;
}) {
return (
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
{headers.map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
rows.map((row, index) => (
<tr className="border-b last:border-b-0" key={index}>
{row.map((cell, cellIndex) => (
<td
className="h-11 max-w-[360px] break-words px-3 align-middle"
key={cellIndex}
>
{cell}
</td>
))}
</tr>
))
) : (
<tr>
<td
className="h-24 px-3 text-center text-sm text-muted-foreground"
colSpan={headers.length}
>
{emptyText}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { MaestrosTable } from "@/components/data-table/maestros-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import {
ACCOUNT_TYPE_LABELS,
ACCOUNT_TYPES,
ACTIVATE_STATUS_LABELS,
ACTIVATE_STATUSES,
} from "@/lib/constants";
import { getMaestros, type RawSearchParams } from "@/lib/maestros";
import { cn } from "@/lib/utils";
type MaestrosPageProps = {
searchParams: Promise<RawSearchParams>;
};
const activateStatusOptions = [
ACTIVATE_STATUSES.PENDING,
ACTIVATE_STATUSES.TRIAL,
ACTIVATE_STATUSES.ACTIVE,
ACTIVATE_STATUSES.CANCELED,
] as const;
const accountTypeOptions = [
ACCOUNT_TYPES.BASIC_20,
ACCOUNT_TYPES.STANDARD_50,
ACCOUNT_TYPES.PRO_100,
ACCOUNT_TYPES.SCHOOL_500,
ACCOUNT_TYPES.SCHOOL_1000,
] as const;
const sortOptions = [
{ value: "id-desc", label: "ID 최신순" },
{ value: "id-asc", label: "ID 오래된순" },
{ value: "joinedAt-desc", label: "가입일 최신순" },
{ value: "joinedAt-asc", label: "가입일 오래된순" },
{ value: "availableAt-desc", label: "만료일 최신순" },
{ value: "availableAt-asc", label: "만료일 오래된순" },
] as const;
export default async function MaestrosPage({
searchParams,
}: MaestrosPageProps) {
const params = await searchParams;
const result = await getMaestros(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-normal"></h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1fr)_160px_180px_190px_110px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="이름, 이메일, ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="activateStatus"
value={result.activateStatus ?? "all"}
>
<option value="all"></option>
{activateStatusOptions.map((status) => (
<option key={status} value={status}>
{ACTIVATE_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="accountType"
value={result.accountType ?? "all"}
>
<option value="all"></option>
{accountTypeOptions.map((accountType) => (
<option key={accountType} value={accountType}>
{ACCOUNT_TYPE_LABELS[accountType]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="sort"
value={result.sort}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href="/maestros"
>
</Link>
</div>
</form>
<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>
<nav
aria-label="마에스트로 목록 페이지"
className="flex flex-wrap items-center gap-1.5"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/maestros?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Database, ShieldCheck, Table2 } from "lucide-react";
import { auth } from "@/auth";
const checks = [
{
label: "Next.js 16",
value: "App Router",
icon: ShieldCheck,
},
{
label: "Prisma",
value: "MariaDB introspected",
icon: Database,
},
{
label: "TanStack Table",
value: "Installed",
icon: Table2,
},
];
export default async function Home() {
const session = await auth();
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-normal"></h1>
<p className="mt-1 text-sm text-muted-foreground">
{session?.user.name} .
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{checks.map((item) => {
const Icon = item.icon;
return (
<article
className="rounded-lg border bg-card p-5 text-card-foreground"
key={item.label}
>
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
<Icon className="size-5" aria-hidden="true" />
</div>
<h2 className="text-base font-medium">{item.label}</h2>
<p className="mt-1 text-sm text-muted-foreground">{item.value}</p>
</article>
);
})}
</div>
</section>
);
}
+240
View File
@@ -0,0 +1,240 @@
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
} from "lucide-react";
import { UpgradeRequestsTable } from "@/components/data-table/upgrade-requests-table";
import { AutoSubmitSelect } from "@/components/forms/auto-submit-select";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUS_LABELS, REQUEST_STATUSES } from "@/lib/constants";
import {
getUpgradeRequests,
type RawSearchParams,
} from "@/lib/upgrade-requests";
import { cn } from "@/lib/utils";
type UpgradeRequestsPageProps = {
searchParams: Promise<RawSearchParams>;
};
const statusOptions = [
REQUEST_STATUSES.REQUESTED,
REQUEST_STATUSES.CLOSED,
REQUEST_STATUSES.APPLIED,
] as const;
export default async function UpgradeRequestsPage({
searchParams,
}: UpgradeRequestsPageProps) {
const params = await searchParams;
const result = await getUpgradeRequests(params);
const paginationItems = buildPaginationItems(result.page, result.totalPages);
const firstVisiblePage = paginationItems[0] ?? 1;
const lastVisiblePage = paginationItems.at(-1) ?? result.totalPages;
const hasFirstPage = paginationItems.includes(1);
const hasLastPage = paginationItems.includes(result.totalPages);
const hasPreviousPageGroup = firstVisiblePage > 1;
const hasNextPageGroup = lastVisiblePage < result.totalPages;
return (
<section className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-normal">
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{result.totalCount.toLocaleString()} {result.page} /{" "}
{result.totalPages}
</p>
</div>
<form
className="grid grid-cols-1 gap-3 rounded-lg border bg-background p-4 sm:grid-cols-2 xl:grid-cols-[minmax(240px,1fr)_160px_110px_auto]"
method="get"
>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<div className="relative min-w-0">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
className="h-9 w-full min-w-0 rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
defaultValue={result.q}
name="q"
placeholder="마에스트로명, ID, 신청ID"
type="search"
/>
</div>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="status"
value={result.status ?? "all"}
>
<option value="all"></option>
{statusOptions.map((status) => (
<option key={status} value={status}>
{REQUEST_STATUS_LABELS[status]}
</option>
))}
</AutoSubmitSelect>
</label>
<label className="min-w-0 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
</span>
<AutoSubmitSelect
className="h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
name="pageSize"
value={result.pageSize}
>
{[10, 20, 50, 100].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</AutoSubmitSelect>
</label>
<div className="flex min-w-0 flex-wrap items-end gap-2 sm:col-span-2 xl:col-span-1">
<Button className="h-9" type="submit">
</Button>
<Link
className={buttonVariants({
variant: "outline",
size: "default",
className: "h-9",
})}
href="/upgrade-requests"
>
</Link>
</div>
</form>
<UpgradeRequestsTable data={result.items} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{result.pageSize}
</p>
<nav
aria-label="업그레이드 신청 목록 페이지"
className="flex flex-wrap items-center gap-1.5"
>
{!hasFirstPage ? (
<Link
aria-label="처음 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, 1)}
>
<ChevronsLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{hasPreviousPageGroup ? (
<Link
aria-label="이전 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, Math.max(1, result.page - 5))}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</Link>
) : null}
{paginationItems.map((item) => (
<Link
aria-current={item === result.page ? "page" : undefined}
className={cn(
buttonVariants({
variant: item === result.page ? "default" : "outline",
size: "sm",
}),
"min-w-8 px-2"
)}
href={buildPageHref(params, item)}
key={item}
>
{item}
</Link>
))}
{hasNextPageGroup ? (
<Link
aria-label="다음 5페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(
params,
Math.min(result.totalPages, result.page + 5)
)}
>
<ChevronRight className="size-4" aria-hidden="true" />
</Link>
) : null}
{!hasLastPage ? (
<Link
aria-label="끝 페이지"
className={buttonVariants({
variant: "outline",
size: "icon-sm",
})}
href={buildPageHref(params, result.totalPages)}
>
<ChevronsRight className="size-4" aria-hidden="true" />
</Link>
) : null}
</nav>
</div>
</section>
);
}
function buildPageHref(rawParams: RawSearchParams, page: number) {
const params = new URLSearchParams();
Object.entries(rawParams).forEach(([key, value]) => {
const firstValue = Array.isArray(value) ? value[0] : value;
if (firstValue && key !== "page") {
params.set(key, firstValue);
}
});
params.set("page", String(Math.max(1, page)));
return `/upgrade-requests?${params.toString()}`;
}
function buildPaginationItems(currentPage: number, totalPages: number) {
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
return Array.from(
{ length: endPage - startPage + 1 },
(_, index) => startPage + index
);
}
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
import { signIn } from "@/auth";
export async function loginAction(formData: FormData) {
const callbackUrl = String(formData.get("callbackUrl") ?? "/");
try {
await signIn("credentials", {
adminName: formData.get("adminName"),
password: formData.get("password"),
redirectTo: callbackUrl,
});
} catch (error) {
if (error instanceof AuthError) {
redirect(
`/login?error=CredentialsSignin&callbackUrl=${encodeURIComponent(callbackUrl)}`
);
}
throw error;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { loginAction } from "./actions";
type LoginPageProps = {
searchParams: Promise<{
callbackUrl?: string;
error?: string;
}>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
const session = await auth();
if (session) {
redirect("/");
}
const params = await searchParams;
const callbackUrl = params.callbackUrl ?? "/";
const hasError = params.error === "CredentialsSignin";
return (
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-10">
<section className="w-full max-w-md rounded-lg border bg-background p-8 shadow-sm">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-normal">chocoadmin</h1>
<p className="mt-2 text-sm text-muted-foreground"> </p>
</div>
{hasError ? (
<div className="mb-5 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
.
</div>
) : null}
<form action={loginAction} className="space-y-5">
<input name="callbackUrl" type="hidden" value={callbackUrl} />
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="adminName">
</label>
<input
autoComplete="username"
autoFocus
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
id="adminName"
name="adminName"
required
type="text"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="password">
</label>
<input
autoComplete="current-password"
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
id="password"
name="password"
required
type="password"
/>
</div>
<Button className="w-full" type="submit">
</Button>
</form>
</section>
</main>
);
}
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "@/auth";
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
approveExtensionRequest,
cancelExtensionRequest,
ExtensionRequestActionError,
} from "@/lib/extension-requests";
const patchBodySchema = z.object({
action: z.enum(["approve", "cancel"]),
});
type ExtensionRequestRouteContext = {
params: Promise<{
id: string;
}>;
};
export async function PATCH(
request: Request,
{ params }: ExtensionRequestRouteContext
) {
const session = await auth();
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) {
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 });
}
await cancelExtensionRequest(maestroExtensionID);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof ExtensionRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getExtensionRequests } from "@/lib/extension-requests";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const result = await getExtensionRequests(url.searchParams);
return NextResponse.json(result);
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMaestroDetail } from "@/lib/maestros";
type MaestroDetailRouteContext = {
params: Promise<{
id: string;
}>;
};
export async function GET(
_request: Request,
{ params }: MaestroDetailRouteContext
) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
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);
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getMaestros } from "@/lib/maestros";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const result = await getMaestros(url.searchParams);
return NextResponse.json(result);
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import {
approveUpgradeRequest,
rejectUpgradeRequest,
UpgradeRequestActionError,
} from "@/lib/upgrade-requests";
const patchBodySchema = z.object({
action: z.enum(["approve", "reject"]),
});
type UpgradeRequestRouteContext = {
params: Promise<{
id: string;
}>;
};
export async function PATCH(
request: Request,
{ params }: UpgradeRequestRouteContext
) {
const session = await auth();
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) {
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 });
}
await rejectUpgradeRequest(maestroUpgradeID);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof UpgradeRequestActionError) {
return NextResponse.json(
{ message: error.message },
{ status: error.statusCode }
);
}
throw error;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getUpgradeRequests } from "@/lib/upgrade-requests";
export async function GET(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const result = await getUpgradeRequests(url.searchParams);
return NextResponse.json(result);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "chocoadmin",
description: "Chocomae admin service",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+115
View File
@@ -0,0 +1,115 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { ACTIVATE_STATUSES } from "@/lib/constants";
const credentialsSchema = z.object({
adminName: z.string().trim().min(1),
password: z.string().min(1),
});
type AdminAuthRow = {
AdminID: bigint | number;
Name: string;
Email: string;
AccountType: bigint | number;
ActivateStatus: bigint | number;
};
declare module "next-auth" {
interface Session {
user: {
adminID: number;
accountType: number;
activateStatus: number;
} & DefaultSession["user"];
}
interface User {
accountType: number;
activateStatus: number;
}
}
declare module "next-auth/jwt" {
interface JWT {
accountType?: number;
activateStatus?: number;
}
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
providers: [
Credentials({
credentials: {
adminName: { label: "관리자 아이디", type: "text" },
password: { label: "암호", type: "password" },
},
async authorize(credentials) {
const parsedCredentials = credentialsSchema.safeParse(credentials);
if (!parsedCredentials.success) {
return null;
}
const { db } = await import("@/lib/db");
const { adminName, password } = parsedCredentials.data;
const admins = await db.$queryRaw<AdminAuthRow[]>`
SELECT AdminID, Name, Email, AccountType, ActivateStatus
FROM admin
WHERE Name = ${adminName} AND Password = PASSWORD(${password})
LIMIT 1
`;
const admin = admins[0];
const adminID = Number(admin?.AdminID);
const accountType = Number(admin?.AccountType);
const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
return null;
}
return {
id: String(adminID),
name: admin.Name,
email: admin.Email,
accountType,
activateStatus,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.accountType = user.accountType;
token.activateStatus = user.activateStatus;
}
return token;
},
session({ session, token }) {
return {
...session,
user: {
...session.user,
adminID: Number(token.sub),
accountType: Number(token.accountType),
activateStatus: Number(token.activateStatus),
},
};
},
},
});
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,199 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, X } from "lucide-react";
import { useState, useTransition } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import { REQUEST_STATUSES } from "@/lib/constants";
import type { ExtensionRequestListItem } from "@/lib/extension-requests";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
} from "@/lib/utils";
type ExtensionRequestsTableProps = {
data: ExtensionRequestListItem[];
};
export function ExtensionRequestsTable({ data }: ExtensionRequestsTableProps) {
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<HeaderCell>ID</HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroExtensionID}
>
<BodyCell>
<span className="font-mono text-xs tabular-nums">
{request.maestroExtensionID}
</span>
</BodyCell>
<BodyCell>
<Link
className={buttonVariants({
variant: "link",
size: "sm",
className: "h-auto p-0 font-medium",
})}
href={`/maestros/${request.maestroID}`}
>
{request.maestroName}
</Link>
<p className="mt-1 text-xs text-muted-foreground">
MaestroID {request.maestroID}
</p>
</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.requestedAccountType)}
</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.currentAccountType)}
</BodyCell>
<BodyCell>
{getActivateStatusLabel(request.activateStatus)}
</BodyCell>
<BodyCell>{formatDate(request.requestedDateTime)}</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
<BodyCell>
<ExtensionRequestActions request={request} />
</BodyCell>
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={9}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function ExtensionRequestActions({
request,
}: {
request: ExtensionRequestListItem;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState("");
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) {
return <span className="text-xs text-muted-foreground"> </span>;
}
async function submitAction(action: "approve" | "cancel") {
const actionLabel = action === "approve" ? "승인" : "취소";
const confirmed = window.confirm(
`${request.maestroName} 연장 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return;
}
setErrorMessage("");
try {
const response = await fetch(
`/api/extension-requests/${request.maestroExtensionID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}
);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new Error(body?.message ?? "연장 신청 처리에 실패했습니다.");
}
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "연장 신청 처리에 실패했습니다."
);
}
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => void submitAction("approve")}
size="sm"
type="button"
>
<Check className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={isPending}
onClick={() => void submitAction("cancel")}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
{errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
);
}
function HeaderCell({ children }: { children: React.ReactNode }) {
return (
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
{children}
</th>
);
}
function BodyCell({ children }: { children: React.ReactNode }) {
return <td className="h-12 px-3 align-middle">{children}</td>;
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { useRouter } from "next/navigation";
import {
flexRender,
getCoreRowModel,
type ColumnDef,
useReactTable,
} from "@tanstack/react-table";
import { useMemo } from "react";
import type { MaestroListItem } from "@/lib/maestros";
import { cn } from "@/lib/utils";
import {
formatDate,
getAccountTypeLabel,
getActivateStatusLabel,
} from "@/lib/utils";
type MaestrosTableProps = {
data: MaestroListItem[];
};
export function MaestrosTable({ data }: MaestrosTableProps) {
const router = useRouter();
const columns = useMemo<ColumnDef<MaestroListItem>[]>(
() => [
{
accessorKey: "maestroID",
header: "ID",
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.maestroID}
</span>
),
},
{
accessorKey: "name",
header: "이름",
cell: ({ row }) => (
<div className="min-w-32">
<p className="font-medium">{row.original.name}</p>
<p className="mt-1 text-xs text-muted-foreground md:hidden">
{row.original.email}
</p>
</div>
),
},
{
accessorKey: "email",
header: "이메일",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span>
),
},
{
accessorKey: "accountType",
header: "계정 유형",
cell: ({ row }) => getAccountTypeLabel(row.original.accountType),
},
{
accessorKey: "activateStatus",
header: "상태",
cell: ({ row }) => (
<span
className={cn(
"inline-flex h-6 items-center rounded-md border px-2 text-xs font-medium",
getActivateStatusBadgeClass(row.original)
)}
>
{getActivateStatusLabel(row.original.activateStatus)}
</span>
),
},
{
accessorKey: "playerCount",
header: "학생 수",
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.playerCount.toLocaleString()}
</span>
),
},
{
accessorKey: "acceptClausesDateTime",
header: "가입일",
cell: ({ row }) => formatDate(row.original.acceptClausesDateTime),
},
{
accessorKey: "availableActivateDateTime",
header: "만료일",
cell: ({ row }) => formatDate(row.original.availableActivateDateTime),
},
],
[]
);
// TanStack Table manages internal functions that React Compiler should not memoize.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] border-collapse text-sm">
<thead className="bg-muted/60">
{table.getHeaderGroups().map((headerGroup) => (
<tr className="border-b" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
className="h-10 px-3 text-left text-xs font-medium text-muted-foreground"
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr
aria-label={`${row.original.name} 마에스트로 상세 보기`}
className="cursor-pointer border-b transition-colors last:border-b-0 hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
key={row.id}
onClick={() => {
router.push(`/maestros/${row.original.maestroID}`);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
router.push(`/maestros/${row.original.maestroID}`);
}
}}
role="link"
tabIndex={0}
>
{row.getVisibleCells().map((cell) => (
<td className="h-12 px-3 align-middle" key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={columns.length}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function getActivateStatusBadgeClass(maestro: MaestroListItem) {
if (
maestro.activateStatus === 2 &&
isBeforeToday(maestro.availableActivateDateTime)
) {
return "border-rose-200 bg-rose-50 text-rose-700";
}
if (maestro.activateStatus === 2) {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (maestro.activateStatus === 100) {
return "border-muted bg-muted text-muted-foreground";
}
return "border-amber-200 bg-amber-50 text-amber-700";
}
function isBeforeToday(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return false;
}
const targetDate = new Date(date);
const today = new Date();
targetDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
return targetDate.getTime() < today.getTime();
}
@@ -0,0 +1,217 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, X } from "lucide-react";
import { useState, useTransition } from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { UpgradeRequestListItem } from "@/lib/upgrade-requests";
import {
formatDate,
formatDateTime,
getAccountTypeLabel,
getActivateStatusLabel,
getRequestStatusLabel,
getTrialAccountTypeLabel,
} from "@/lib/utils";
type UpgradeRequestsTableProps = {
data: UpgradeRequestListItem[];
};
export function UpgradeRequestsTable({ data }: UpgradeRequestsTableProps) {
return (
<div className="overflow-hidden rounded-lg border bg-background">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] border-collapse text-sm">
<thead className="bg-muted/60">
<tr className="border-b">
<HeaderCell>ID</HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell></HeaderCell>
<HeaderCell> </HeaderCell>
<HeaderCell></HeaderCell>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.map((request) => (
<tr
className="border-b last:border-b-0"
key={request.maestroUpgradeID}
>
<BodyCell>
<span className="font-mono text-xs tabular-nums">
{request.maestroUpgradeID}
</span>
</BodyCell>
<BodyCell>
<Link
className={buttonVariants({
variant: "link",
size: "sm",
className: "h-auto p-0 font-medium",
})}
href={`/maestros/${request.maestroID}`}
>
{request.maestroName}
</Link>
<p className="mt-1 text-xs text-muted-foreground">
MaestroID {request.maestroID}
</p>
</BodyCell>
<BodyCell>
{getActivateStatusLabel(request.registeredActivateStatus)}
</BodyCell>
<BodyCell>{getRegisteredAccountLabel(request)}</BodyCell>
<BodyCell>
{getAccountTypeLabel(request.requestedAccountType)}
</BodyCell>
<BodyCell>
<span className="tabular-nums">
{request.additionalPrice.toLocaleString()}
</span>
</BodyCell>
<BodyCell>
{formatDateTime(request.requestedDateTime)}
</BodyCell>
<BodyCell>
{formatDate(request.availableActivateDateTime)}
</BodyCell>
<BodyCell>{getRequestStatusLabel(request.status)}</BodyCell>
<BodyCell>
<UpgradeRequestActions request={request} />
</BodyCell>
</tr>
))
) : (
<tr>
<td
className="h-28 px-3 text-center text-sm text-muted-foreground"
colSpan={10}
>
.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function UpgradeRequestActions({
request,
}: {
request: UpgradeRequestListItem;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState("");
const isRequested = request.status === REQUEST_STATUSES.REQUESTED;
if (!isRequested) {
return <span className="text-xs text-muted-foreground"> </span>;
}
async function submitAction(action: "approve" | "reject") {
const actionLabel = action === "approve" ? "승인" : "거절";
const confirmed = window.confirm(
`${request.maestroName} 업그레이드 신청을 ${actionLabel}하시겠습니까?`
);
if (!confirmed) {
return;
}
setErrorMessage("");
try {
const response = await fetch(
`/api/upgrade-requests/${request.maestroUpgradeID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}
);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new Error(
body?.message ?? "업그레이드 신청 처리에 실패했습니다."
);
}
startTransition(() => {
router.refresh();
});
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "업그레이드 신청 처리에 실패했습니다."
);
}
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => void submitAction("approve")}
size="sm"
type="button"
>
<Check className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={isPending}
onClick={() => void submitAction("reject")}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
{errorMessage ? (
<p className="max-w-56 text-xs text-destructive">{errorMessage}</p>
) : null}
</div>
);
}
function getRegisteredAccountLabel(request: UpgradeRequestListItem) {
if (request.registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
return getTrialAccountTypeLabel(request.registeredAccountType);
}
return getAccountTypeLabel(request.registeredAccountType);
}
function HeaderCell({ children }: { children: React.ReactNode }) {
return (
<th className="h-10 px-3 text-left text-xs font-medium text-muted-foreground">
{children}
</th>
);
}
function BodyCell({ children }: { children: React.ReactNode }) {
return <td className="h-12 px-3 align-middle">{children}</td>;
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import type { ComponentProps } from "react";
type AutoSubmitSelectProps = ComponentProps<"select">;
export function AutoSubmitSelect({
onChange,
...props
}: AutoSubmitSelectProps) {
return (
<select
{...props}
onChange={(event) => {
onChange?.(event);
event.currentTarget.form?.requestSubmit();
}}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+16
View File
@@ -0,0 +1,16 @@
services:
chocoadmin:
build:
context: .
dockerfile: Dockerfile
image: chocoadmin:latest
container_name: chocoadmin
ports:
- "3001:3000"
env_file:
- ${ENV_FILE:-.env.production}
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
restart: unless-stopped
+354
View File
@@ -0,0 +1,354 @@
# 기존 관리자 비즈니스 규칙
작성일: 2026-06-09
## 1. 개요
이 문서는 기존 `mouse-typing` 관리자 기능을 조사해 신규 `chocoadmin`에서 재현해야 할 비즈니스 규칙을 정리한 것이다.
조사 대상:
- 기존 서비스: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
- 관리자 화면: `src/web/admin/`
- 관리자 서버: `src/web/server/admin/`
- 공통 로그 유틸: `src/web/server/lib/maestro_log.php`
- 계정 표시/계산 유틸: `src/web/js/lib/account_info.js`, `src/web/js/maestro_available_date.js`
중요 결론:
- 관리자 비밀번호는 MariaDB `PASSWORD()` 함수로 검증한다.
- 연장 승인은 기존 만료일 기준 1년 연장하되, 만료된 계정은 오늘 기준 1년 연장한다.
- 기존 연장 승인 서버는 날짜를 직접 계산하지 않고 화면에서 전달한 `new_available_date`를 저장한다.
- 업그레이드 승인은 `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`만 변경하고 `PlayerCount`는 변경하지 않는다.
- 기존 관리자에는 연장 취소/업그레이드 거절 버튼이 없다. 승인 처리 중 같은 마에스트로의 다른 요청을 `Status=2`로 닫는 동작만 있다.
- 기존 변경 처리에는 명시적 트랜잭션이 없다.
## 2. 기존 관리자 파일 맵
| 기존 화면 | 서버 파일 | 역할 | 신규 `chocoadmin` 매핑 |
|---|---|---|---|
| `admin/login.html` | `server/admin/login.php` | 관리자 로그인 | `/login`, NextAuth Credentials Provider |
| `admin/admin_all_list.html` | `server/admin/maestro_all_list.php` | 전체 마에스트로 목록 조회 | `/maestros`, `GET /api/maestros` |
| `admin/admin_register_list.html` | `server/admin/maestro_registered_list.php`, `server/admin/register_maestro.php` | 미승인 마에스트로 조회 및 등록 승인 | MVP 외 또는 상세 보조 규칙 |
| `admin/admin_extension_list.html` | `server/admin/maestro_extension_list.php`, `server/admin/extension_maestro.php` | 연장 요청 조회 및 승인 | `/extension-requests`, `PATCH /api/extension-requests/[id]` |
| `admin/admin_upgrade_list.html` | `server/admin/maestro_upgrade_list.php`, `server/admin/upgrade_maestro.php` | 업그레이드 요청 조회 및 승인 | `/upgrade-requests`, `PATCH /api/upgrade-requests/[id]` |
| `admin/admin_expiration_list.html` | 없음 | 정적 골격만 존재, 실제 조회 로직 없음 | MVP 제외 |
| `admin/admin_send_email.html` | `server/mail/send_mail_test.php` | 테스트 메일 발송 화면 | MVP 제외 |
| `admin/admin_header.html` | 없음 | 관리자 메뉴 링크 | 공통 사이드바/헤더 |
## 3. 상태값 코드표
### `maestro.AccountType`
| 값 | 의미 | 정원 | 금액 |
|---:|---|---:|---:|
| 1 | 20명 | 20 | 10,000 |
| 2 | 50명 | 50 | 20,000 |
| 3 | 100명 | 100 | 30,000 |
| 4 | 500명 | 500 | 40,000 |
| 5 | 1,000명 | 1,000 | 50,000 |
| 100 | 학생체험 | - | - |
| 101 | 마에체험 | - | - |
### `maestro.ActivateStatus`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 0 | 미승인 / 입금대기 | 가입 후 관리자 승인 전 |
| 1 | 체험 | 체험 계정 |
| 2 | 활성화 | 유료 활성 계정 |
| 100 | 등록 취소 / 환불 | 등록 취소 또는 환불 상태 |
### `maestro_extension.Status`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 1 | 요청 | 연장 요청 |
| 2 | 취소 | 닫힌 요청 |
| 3 | 업그레이드 적용 | 코드상 공통 표시 문구이나 실제 의미는 연장 적용 |
### `maestro_upgrade.Status`
| 값 | 기존 표시 | 의미 |
|---:|---|---|
| 1 | 요청 | 업그레이드 요청 |
| 2 | 취소 | 닫힌 요청 |
| 3 | 업그레이드 적용 | 업그레이드 적용 |
## 4. 로그인/관리자 인증 규칙
기존 흐름:
1. `login.html`에서 `admin_name`, `password``server/admin/login.php`로 POST한다.
2. `get_admin_id($adminName)`에서 `admin.Name = ?` 조건으로 관리자 존재 여부를 먼저 확인한다.
3. `login($adminName, $password)`에서 `admin.Name = ? AND admin.Password = PASSWORD(?)` 조건으로 검증한다.
4. 응답 데이터로 `adminID`, `accountType`, `activateStatus`를 반환한다.
실패 조건:
| 조건 | error_code | message |
|---|---|---|
| `Name`에 해당하는 관리자 없음 | `not_registered_id` | 등록되지 않은 서비스 관리자 아이디입니다. |
| 비밀번호 검증 결과 없음 | `no_data` 또는 `wrong_password` | 로그인하지 못했습니다. / 비밀번호가 틀렸습니다. |
| `ActivateStatus === 0` | `not_activated` | 서비스 관리자 계정이 아직 활성화되지 않았습니다. |
신규 구현 규칙:
- 기존 계정 호환을 위해 최초 구현은 MariaDB `PASSWORD()` 함수를 이용해 검증한다.
- NextAuth 세션에는 최소 `adminID`, `accountType`, `activateStatus`를 포함한다.
- `ActivateStatus = 0` 관리자는 로그인시키지 않는다.
- `PASSWORD()`는 보안상 낡은 방식이므로, 해시 업그레이드는 별도 마이그레이션 과제로 분리한다.
## 5. 마에스트로 목록 조회 규칙
### 전체 목록
기존 서버: `server/admin/maestro_all_list.php`
검색어가 없을 때:
- 테이블: `maestro`
- 컬럼: `MaestroID`, `Name`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `AcceptClausesDateTime`, `PlayerCount`
- 정렬: `MaestroID DESC`
검색어가 있을 때:
- 조건: `Name LIKE CONCAT('%', ?, '%')`
- 정렬: `MaestroID DESC`
- 주의: 기존 검색 쿼리는 `AvailableActivateDateTime`을 조회하지 않는데 화면은 해당 값을 사용한다. 신규 구현에서는 전체/검색 결과 모두 동일 컬럼을 반환해야 한다.
기존 화면 표시:
- 상태 텍스트는 `accountStatus(accountType, activateStatus)`로 계산한다.
- `AvailableActivateDateTime``0`으로 시작하면 빈 문자열로 표시한다.
- 비활성화 버튼은 disabled 상태이며 동작하지 않는다.
신규 구현 규칙:
- `Name`, `Email`, `MaestroID` 검색을 지원하되 기존 기본 정렬은 `MaestroID DESC`로 시작한다.
- 전체/검색 결과의 반환 컬럼 차이를 만들지 않는다.
### 미승인 등록 목록
기존 서버: `server/admin/maestro_registered_list.php`
- 테이블: `maestro`
- 조건: `ActivateStatus = 0`
- 검색 조건: `Name LIKE CONCAT('%', ?, '%') AND ActivateStatus = 0`
- 컬럼: `MaestroID`, `Name`, `AccountType`, `AcceptClausesDateTime`
- 정렬: `MaestroID DESC`
## 6. 등록/활성화 처리 규칙
기존 서버: `server/admin/register_maestro.php`
처리 순서:
1. `maestro.ActivateStatus = 1`로 변경한다.
2. `maestro.AvailableActivateDateTime = '2022-07-31 23:59:59'`로 변경한다.
3. `player`에 테스트 플레이어 `Name='마에스트로'`, `EnterCode=''`, `AccountType=1`을 추가한다.
4. 추가된 테스트 플레이어를 `maestro.MaestroTestID`에 저장한다.
5. `app.Status = 1`인 앱을 모두 `active_app`에 추가한다.
6. 샘플 학생 4명을 `player.AccountType = 0`으로 추가한다.
7. `player.AccountType = 0` 학생 수를 세어 `maestro.PlayerCount`에 저장한다.
8. 등록 완료 메일을 발송한다.
9. `maestro_log``register_maestro` 로그를 기록한다.
주의:
- 코드에는 `DATE_ADD(NOW(), INTERVAL 1 YEAR)``DATE_ADD(NOW(), INTERVAL 14 DAY)`가 주석으로 남아 있지만 실제 실행 쿼리는 고정일 `2022-07-31 23:59:59`다.
- 현재 MVP의 핵심 변경 기능은 연장/업그레이드이므로 등록 승인은 별도 범위로 둘 수 있다.
## 7. 연장 신청 조회/승인 규칙
### 요청 생성
기존 서버: `server/maestro/request_extension_maestro.php`
- `maestro_extension``AccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
- INSERT 후 `MaestroID` 기준 전체 연장 요청 수가 1개 이상인지 단순 확인한다.
- 입금 안내 메일을 발송한다.
### 관리자 목록 조회
기존 서버: `server/admin/maestro_extension_list.php`
검색어가 없을 때:
- 테이블: `maestro_extension E`, `maestro M`
- 조건: `E.Status < 2 AND E.MaestroID = M.MaestroID`
- 컬럼: `E.MaestroExtensionID`, `E.MaestroID`, `M.Name`, `E.AccountType`, `E.RequestedDateTime`, `M.AvailableActivateDateTime`, `E.Status`
- 정렬: `E.MaestroExtensionID DESC`
검색어가 있을 때:
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND E.MaestroID = M.MaestroID`
- 주의: 검색 쿼리에는 `E.Status < 2` 조건이 없다. 신규 구현에서는 기본 정책에 맞게 상태 필터를 명시적으로 제공해야 한다.
### 관리자 승인
기존 화면: `admin/admin_extension_list.html`
- `availableActivateDateTime`의 날짜 부분을 기준으로 새 만료일을 계산한다.
- 계산 유틸: `src/web/js/maestro_available_date.js`
- 현재 만료일이 지났으면 오늘 기준 1년 후 날짜를 사용한다.
- 현재 만료일이 지나지 않았으면 기존 만료일 기준 1년 후 날짜를 사용한다.
- 서버에는 `new_available_date` 문자열을 POST한다.
기존 서버: `server/admin/extension_maestro.php`
처리 순서:
1. `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime = DATE_FORMAT(?, '%Y-%m-%d')`로 변경한다.
2. 같은 `MaestroID``maestro_extension.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
3. 선택한 `MaestroExtensionID` 요청을 `Status = 3`으로 변경한다.
4. 연장 완료 메일을 발송한다.
5. `maestro_log``extension_maestro` 로그를 기록한다.
로그:
- `Type`: `extension_maestro`
- `Remark`: `{maestro_name}({registered_account_type})`
신규 구현 규칙:
- 날짜 계산은 클라이언트가 아니라 서버에서 수행한다.
- 기존 규칙과 동일하게 만료 전이면 기존 만료일 기준 +1년, 만료 후면 현재일 기준 +1년으로 계산한다.
- `AvailableActivateDateTime` 저장 시 기존 서버는 날짜만 저장하므로 시간은 DB 기본 파싱 결과를 고려해야 한다. 신규 구현에서는 일관성을 위해 `YYYY-MM-DD 00:00:00` 또는 기존 호환 형식을 명시한다.
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
- 트랜잭션 안에서 `maestro`, `maestro_extension`, `maestro_log`를 함께 처리한다.
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
## 8. 업그레이드 신청 조회/승인 규칙
### 요청 생성
기존 서버: `server/maestro/request_upgrade_maestro.php`
- `maestro_upgrade``RegisteredActivateStatus`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime = NOW()`, `Status = 1`, `MaestroID`를 INSERT한다.
- 같은 마에스트로의 기존 요청 중복 여부는 검사하지 않는다.
- INSERT 후 `MaestroID` 기준 전체 업그레이드 요청 수가 1개 이상인지 단순 확인한다.
- 체험 계정이면 체험 업그레이드 입금 안내 메일, 그 외에는 일반 업그레이드 입금 안내 메일을 발송한다.
### 관리자 목록 조회
기존 서버: `server/admin/maestro_upgrade_list.php`
검색어가 없을 때:
- 테이블: `maestro_upgrade U`, `maestro M`
- 조건: `U.Status < 2 AND U.MaestroID = M.MaestroID`
- 컬럼: `U.MaestroUpgradeID`, `U.MaestroID`, `M.Name`, `U.RegisteredActivateStatus`, `U.RegisteredAccountType`, `U.RequestedAccountType`, `U.RequestedDateTime`, `M.AvailableActivateDateTime`, `U.Status`
- 정렬: `U.MaestroUpgradeID DESC`
검색어가 있을 때:
- 조건: `M.Name LIKE CONCAT('%', ?, '%') AND U.MaestroID = M.MaestroID`
- 주의: 검색 쿼리에는 `U.Status < 2` 조건이 없다. 신규 구현에서는 상태 필터를 명시적으로 제공해야 한다.
### 관리자 승인
기존 화면: `admin/admin_upgrade_list.html`
- `Status = 1`인 요청만 승인 버튼을 표시한다.
- 체험 계정(`RegisteredActivateStatus = 1`)은 기존 요금제를 `trialAccountType`으로 표시하고 추가 금액은 요청 요금제 전체 금액으로 계산한다.
- 그 외 계정은 추가 금액을 `RequestedAccountType` 금액 - `RegisteredAccountType` 금액으로 계산한다.
기존 서버: `server/admin/upgrade_maestro.php`
처리 순서:
1. `maestro.AccountType = upgrade_account_type`으로 변경한다.
2. `maestro.ActivateStatus = 2`로 변경한다.
3. `maestro.AvailableActivateDateTime = DATE_ADD(NOW(), INTERVAL 1 YEAR)`로 변경한다.
4. 같은 `MaestroID``maestro_upgrade.Status = 1` 요청을 모두 `Status = 2`로 변경한다.
5. 선택한 `MaestroUpgradeID` 요청을 `Status = 3`으로 변경한다.
6. 체험 계정이면 체험 업그레이드 완료 메일, 그 외에는 일반 업그레이드 완료 메일을 발송한다.
7. `maestro_log``upgrade_maestro` 로그를 기록한다.
로그:
- `Type`: `upgrade_maestro`
- `Remark`: `{registered_account_type} -> {upgrade_account_type}`
확정 사항:
- `PlayerCount`는 변경하지 않는다.
- `AvailableActivateDateTime`은 기존 만료일 기준이 아니라 승인 시점 `NOW()` 기준 1년 후로 설정한다.
신규 구현 규칙:
- 승인 전 선택한 요청이 `Status = 1`인지 확인한다.
- 트랜잭션 안에서 `maestro`, `maestro_upgrade`, `maestro_log`를 함께 처리한다.
- 같은 마에스트로의 다른 `Status = 1` 요청을 `Status = 2`로 닫은 뒤 선택 요청을 `Status = 3`으로 적용하는 기존 동작을 재현한다.
- `PlayerCount`는 업그레이드 승인에서 변경하지 않는다.
## 9. 취소/거절 규칙
기존 관리자 UI/서버에서 확인된 사실:
- 연장 신청에 대한 별도 취소 버튼이나 취소 API는 없다.
- 업그레이드 신청에 대한 별도 거절 버튼이나 거절 API는 없다.
- `Status = 2`는 승인 처리 중 같은 마에스트로의 다른 미처리 요청을 닫기 위해 사용된다.
- 화면 유틸 `status()``Status = 2`를 "취소"로 표시하지만, 관리자가 명시적으로 취소/거절하는 흐름은 구현되어 있지 않다.
신규 구현 판단:
- `docs/plan/project-plan.md`의 취소/거절 기능은 기존 관리자 재현이 아니라 신규 보완 기능이다.
- 취소/거절 API를 만들 경우 `Status = 1`인 단일 요청만 `Status = 2`로 변경하고 `maestro_log`에 별도 로그를 남기는 방식으로 정의해야 한다.
- 기존 `maestro_log.Type`에는 취소/거절 전용 타입이 없으므로 신규 타입을 도입할지 기존 타입에 `Remark`로 표현할지 결정이 필요하다.
권장 신규 로그 타입:
- `cancel_extension_maestro`
- `reject_upgrade_maestro`
## 10. `maestro_log` 기록 규칙
공통 함수: `src/web/server/lib/maestro_log.php`
```sql
INSERT INTO maestro_log (Type, MaestroID, LogDateTime, Remark)
VALUES (?, ?, NOW(), ?)
```
확인된 로그 타입:
| Type | 발생 위치 | Remark 형식 |
|---|---|---|
| `add_maestro` | `server/maestro/add_maestro.php` | `{maestro_name} / {email} / {account_type}` |
| `register_maestro` | `server/admin/register_maestro.php` | `{name} / {email}` |
| `extension_maestro` | `server/admin/extension_maestro.php` | `{maestro_name}({registered_account_type})` |
| `upgrade_maestro` | `server/admin/upgrade_maestro.php` | `{registered_account_type} -> {upgrade_account_type}` |
| `update_maestro_name` | `server/maestro/update_maestro_info.php` | `{previous_name} -> {new_name}` |
| `update_maestro_email` | `server/maestro/update_maestro_info.php` | `{previous_email} -> {new_email}` |
확인 결과:
- `update_maestro` 타입은 코드 검색에서 확인되지 않았다.
- 로그에는 관리자 ID나 관리자 이름이 포함되지 않는다.
- 로그 시간은 DB 서버의 `NOW()`를 사용한다.
- `Remark` 컬럼은 `char(100)`이므로 신규 구현에서도 길이 초과에 주의한다.
## 11. 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
- 운영 DB 스키마는 변경하지 않는다.
- 관리자 로그인은 기존 `admin.Name``admin.Password = PASSWORD(?)` 호환 검증을 지원한다.
- 모든 변경 API는 서버에서 현재 상태를 다시 조회하고 `Status = 1`인지 확인한다.
- 승인/취소/거절 같은 변경 작업은 트랜잭션으로 묶는다.
- 연장 승인은 서버에서 새 만료일을 계산한다.
- 업그레이드 승인은 `PlayerCount`를 변경하지 않는다.
- 연장/업그레이드 승인 시 같은 마에스트로의 다른 요청을 `Status = 2`로 닫고 선택 요청을 `Status = 3`으로 적용한다.
- 기존 목록 검색 쿼리의 상태 필터 누락은 그대로 복제하지 말고 명시적 필터로 보완한다.
- 메일 발송은 트랜잭션 커밋 이후 수행하는 것이 안전하다. 기존 코드는 DB 변경 후 메일을 발송한다.
## 12. 기존 코드에는 없지만 신규 구현에서 보완할 사항
- 명시적 트랜잭션: 기존 관리자 변경 로직에는 `BEGIN`, `COMMIT`, `ROLLBACK`이 없다.
- 중복 처리 방지: 기존 승인 서버는 요청의 현재 `Status`를 먼저 검증하지 않는다.
- 서버 사이드 날짜 계산: 기존 연장 승인은 클라이언트 계산값을 신뢰한다.
- 검색 필터 일관성: 연장/업그레이드 검색 쿼리에 `Status < 2` 조건이 빠져 있다.
- 목록 컬럼 일관성: 전체 마에스트로 검색 쿼리에 `AvailableActivateDateTime`이 빠져 있다.
- 취소/거절 정의: 기존에는 별도 액션이 없으므로 신규 기능으로 명세화해야 한다.
- 감사성 강화: 기존 `maestro_log`에는 관리자 식별자가 없으므로 세션 기반 감사 로그가 필요하면 별도 설계가 필요하다. 단, DB 스키마 변경 없이 구현하려면 `Remark`에 관리자 정보를 포함하는 방식만 가능하다.
+76
View File
@@ -0,0 +1,76 @@
# chocoadmin Deployment
## 1. Production Environment
Create `.env.production` on the production host. Do not commit it.
```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:3001"
```
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
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:3001/login`.
Equivalent CLI command:
```bash
docker compose up -d --build
```
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.
To run with stage settings:
```bash
ENV_FILE=.env.stage docker compose up -d --build
```
## 3. AWS EC2 Security Group
Allow MariaDB only from the Synology NAS public IP.
- Type: `MYSQL/Aurora`
- Protocol: `TCP`
- Port: `3306`
- Source: `NAS_PUBLIC_IP/32`
- Description: `chocoadmin Synology NAS`
Do not open `3306` to `0.0.0.0/0`.
## 4. Read-Only Rehearsal
Before enabling approval/rejection operations against production DB:
1. Create or use a DB account with read-only permissions.
2. Set `DATABASE_URL` in `.env.production` to that read-only account.
3. Start the container.
4. Verify login, maestro list, extension request list, and upgrade request list.
5. Switch to the production write-capable chocoadmin DB account only after read screens work.
## 5. Smoke Checks
After container start:
```bash
docker compose ps
docker compose logs -f chocoadmin
```
Verify these routes in the browser:
- `/login`
- `/maestros`
- `/extension-requests`
- `/upgrade-requests`
+285
View File
@@ -0,0 +1,285 @@
# Phase 0 작업 계획
## 1. 목적
`docs/plan/project-plan.md``9. 개발 단계 계획 > Phase 0. 기존 관리자 코드 조사`를 수행하기 위한 실행 계획이다.
Phase 0의 목표는 기존 `mouse-typing` 관리자 기능의 비즈니스 규칙을 확인하고, 신규 `chocoadmin`에서 동일하게 재현해야 할 규칙을 `docs/business-rules.md`에 문서화하는 것이다.
## 2. 조사 대상
기존 서비스 경로:
- `/Users/jisangs/work/jinaju/git/web/mouse-typing`
우선 조사 디렉터리:
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin/`
- `/Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/`
주요 서버 파일:
- `login.php`
- `maestro_all_list.php`
- `maestro_registered_list.php`
- `maestro_extension_list.php`
- `extension_maestro.php`
- `maestro_upgrade_list.php`
- `upgrade_maestro.php`
- `register_maestro.php`
- `activate_stage.php`
주요 화면 파일:
- `login.html`
- `admin_all_list.html`
- `admin_register_list.html`
- `admin_extension_list.html`
- `admin_upgrade_list.html`
- `admin_expiration_list.html`
- `admin_header.html`
## 3. 확인해야 할 핵심 질문
### 3-1. 관리자 라우트와 기능 구조
- 관리자 화면별 HTML 파일과 서버 PHP 파일의 호출 관계를 확인한다.
- 각 화면의 목록 조회 조건, 검색 조건, 정렬 기준, 상태 필터를 확인한다.
- 신규 `chocoadmin`의 화면/API 구조와 직접 매핑 가능한 기능 목록을 정리한다.
산출물:
- 기존 화면 -> 기존 서버 파일 -> 신규 화면/API 매핑표
### 3-2. 연장 승인 규칙
- `extension_maestro.php`에서 연장 승인 처리 흐름을 확인한다.
- `maestro_extension.Status` 변경값과 변경 조건을 확인한다.
- `maestro.AvailableActivateDateTime` 계산 방식을 확인한다.
- `maestro.ActivateStatus` 변경 여부와 값을 확인한다.
- 실패/중복 처리 조건이 있는지 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- 계정 유형별 차이 없이 1년 연장
검증 기준:
- 기존 코드에서 날짜 계산 로직이 `+1 year`, `+12 month`, 고정 일수 중 무엇인지 확인한다.
- 만료일이 과거인 경우 기준일이 현재일인지 기존 만료일인지 확인한다.
### 3-3. 업그레이드 승인 규칙
- `upgrade_maestro.php`에서 업그레이드 승인 처리 흐름을 확인한다.
- `maestro_upgrade.Status` 변경값과 변경 조건을 확인한다.
- `maestro.AccountType` 변경 기준을 확인한다.
- `maestro.PlayerCount`를 즉시 변경하는지 확인한다.
- 거절 처리 시 변경되는 테이블과 로그를 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- `AccountType`만 변경
- `PlayerCount`는 변경하지 않음
검증 기준:
- 승인 SQL에 `PlayerCount` 업데이트가 포함되는지 확인한다.
- 목록 조회 시 `RegisteredAccountType`, `RequestedAccountType`을 어떻게 표시하는지 확인한다.
### 3-4. 관리자 비밀번호 검증 방식
- `login.php`에서 `admin.Password` 검증 SQL을 확인한다.
- MariaDB `PASSWORD()` 함수 사용 여부를 확인한다.
- 세션에 저장되는 관리자 식별자와 권한 정보를 확인한다.
- 실패 응답 형식과 비활성 관리자 처리 여부를 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 가정:
- MariaDB `PASSWORD()` 함수로 저장/검증
검증 기준:
- `WHERE Password = PASSWORD(?)` 형태인지 확인한다.
- `ActivateStatus`, `AccountType` 같은 추가 조건이 있는지 확인한다.
### 3-5. `maestro_log` 기록 형식
- 관리자 변경 액션이 `maestro_log`에 기록되는 모든 지점을 찾는다.
- `Type` 값 목록을 확인한다.
- `Remark` 문자열 형식과 포함 정보, 날짜 형식을 확인한다.
- 로그 작성 시 관리자 ID/이름이 포함되는지 확인한다.
현재 `docs/plan/project-plan.md`에 기록된 `Type` 후보:
- `add_maestro`
- `update_maestro`
- `register_maestro`
- `update_maestro_name`
- `extension_maestro`
- `upgrade_maestro`
- `update_maestro_email`
검증 기준:
- 중복 기재된 값과 실제 미사용 값을 정리한다.
- 각 액션별 `Remark` 예시를 코드 기준으로 문서화한다.
## 4. 작업 순서
### Step 1. 파일 인벤토리 작성
- 관리자 HTML/PHP 파일 목록을 확정한다.
- 각 파일의 역할을 한 줄로 요약한다.
- AJAX 호출 URL, form action, query parameter를 찾는다.
완료 기준:
- `docs/business-rules.md`에 "기존 관리자 파일 맵" 섹션 초안 작성
### Step 2. 로그인 로직 조사
- `login.html``login.php`를 연결해 인증 흐름을 추적한다.
- DB 조회 조건, 비밀번호 검증 방식, 세션 키를 기록한다.
- 신규 NextAuth Credentials Provider에서 재현해야 할 조건을 정리한다.
완료 기준:
- `admin.Password` 검증 방식 확정
- 로그인 성공/실패 조건 문서화
### Step 3. 목록 조회 로직 조사
- 전체 마에스트로 목록, 등록 신청 목록, 연장 신청 목록, 업그레이드 신청 목록 SQL을 확인한다.
- 조인 테이블, 상태 필터, 기본 정렬, 표시 컬럼을 정리한다.
- 신규 API 쿼리 설계에 필요한 컬럼 목록을 분리한다.
완료 기준:
- 신규 목록 API별 필수 SQL 조건 문서화
### Step 4. 등록/활성화 처리 조사
- `register_maestro.php``activate_stage.php`의 상태 변경 로직을 확인한다.
- `maestro.ActivateStatus`, `AvailableActivateDateTime`, `maestro_log` 처리 규칙을 기록한다.
- MVP 범위에 직접 포함되지 않더라도 상세 화면과 로그 해석에 필요한 규칙을 정리한다.
완료 기준:
- 마에스트로 상태값 변경 규칙 문서화
### Step 5. 연장 승인/취소 처리 조사
- `maestro_extension_list.php`에서 목록 조회 조건을 확인한다.
- `extension_maestro.php`에서 승인/취소 처리 SQL을 순서대로 기록한다.
- 트랜잭션 사용 여부와 중복 처리 방지 조건을 확인한다.
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
완료 기준:
- 연장 승인 시 날짜 계산 기준 확정
- 연장 취소 시 상태값과 로그 형식 확정
### Step 6. 업그레이드 승인/거절 처리 조사
- `maestro_upgrade_list.php`에서 목록 조회 조건을 확인한다.
- `upgrade_maestro.php`에서 승인/거절 처리 SQL을 순서대로 기록한다.
- `AccountType`, `PlayerCount`, `maestro_upgrade.Status`, `maestro_log` 변경 규칙을 확인한다.
- 신규 구현에서 보완해야 할 원자성/중복 방지 항목을 별도로 표시한다.
완료 기준:
- 업그레이드 승인 시 변경 컬럼 확정
- 업그레이드 거절 시 상태값과 로그 형식 확정
### Step 7. 비즈니스 규칙 문서 작성
`docs/business-rules.md`를 생성하고 다음 구조로 정리한다.
- 개요
- 기존 관리자 파일 맵
- 상태값 코드표
- 로그인/관리자 인증 규칙
- 마에스트로 목록 조회 규칙
- 등록/활성화 처리 규칙
- 연장 신청 조회/승인/취소 규칙
- 업그레이드 신청 조회/승인/거절 규칙
- `maestro_log` 기록 규칙
- 신규 `chocoadmin` 구현 시 반드시 지켜야 할 규칙
- 기존 코드에는 없지만 신규 구현에서 보완할 사항
완료 기준:
- Phase 1 이후 구현자가 기존 PHP 코드를 다시 열지 않아도 핵심 규칙을 확인할 수 있어야 한다.
## 5. 조사 방법
권장 검색 명령:
```bash
rg -n "maestro_log|extension_maestro|upgrade_maestro|PASSWORD|AvailableActivateDateTime|PlayerCount|ActivateStatus|AccountType|Status" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin
```
파일별 상세 확인:
```bash
sed -n '1,240p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/login.php
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/extension_maestro.php
sed -n '1,260p' /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/server/admin/upgrade_maestro.php
```
HTML 호출부 확인:
```bash
rg -n "ajax|fetch|XMLHttpRequest|form|server/admin|extension_maestro|upgrade_maestro|login.php" /Users/jisangs/work/jinaju/git/web/mouse-typing/src/web/admin
```
## 6. 완료 산출물
Phase 0 완료 시 저장소에 다음 문서가 있어야 한다.
- `docs/business-rules.md`
- 기존 관리자 비즈니스 규칙의 최종 문서
- `docs/phase/phase0.md`
- Phase 0 수행 계획 문서
필요하면 다음 보조 자료를 `docs/` 아래에 추가한다.
- `docs/admin-file-map.md`
- 파일 매핑이 길어질 경우 분리
- `docs/sql-notes.md`
- 기존 SQL 원문 기록이 길어질 경우 분리
## 7. 완료 체크리스트
- [x] `src/web/admin/` 화면 파일과 `src/web/server/admin/` 처리 파일의 연결 관계를 정리했다.
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식을 코드 기준으로 확정했다.
- [x] 연장 승인/취소 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부를 코드 기준으로 확정했다.
- [x] 업그레이드 승인/거절 시 변경되는 테이블, 컬럼, 상태값, 로그 형식을 정리했다.
- [x] `admin.Password` 저장/검증 방식을 코드 기준으로 확정했다.
- [x] `maestro_log.Type` 값과 `Remark` 형식을 액션별로 정리했다.
- [x] 기존 코드에 트랜잭션/중복 처리 방지가 부족한 지점을 신규 구현 보완사항으로 표시했다.
- [x] 조사 결과를 `docs/business-rules.md`에 문서화했다.
- [x] `docs/plan/project-plan.md`의 Phase 0 체크리스트를 완료 처리할 수 있는 상태가 되었다.
## 8. 리스크와 주의사항
- 기존 PHP 코드가 트랜잭션 없이 여러 SQL을 순차 실행할 수 있다. 신규 서비스는 기존 결과는 동일하게 유지하되, 변경 액션은 트랜잭션으로 보완한다.
- 기존 코드에 중복 처리 방지 조건이 없더라도 신규 API는 `Status = 1` 조건 확인 후 변경해야 한다.
- MariaDB `PASSWORD()` 함수는 최신 인증용 해시로 적합하지 않다. Phase 0에서는 기존 검증 방식을 정확히 재현하고, 해시 업그레이드는 별도 후속 과제로 분리한다.
- 운영 DB 스키마는 임의 변경하지 않는다.
- 문서화 시 추측과 코드로 확인된 사실을 구분해 적는다.
## 9. 예상 일정
| 순서 | 작업 | 예상 소요 |
|---|---|---:|
| 1 | 파일 인벤토리와 호출 관계 정리 | 30분 |
| 2 | 로그인/인증 로직 조사 | 20분 |
| 3 | 목록 조회 SQL 조사 | 40분 |
| 4 | 등록/활성화 처리 조사 | 30분 |
| 5 | 연장 승인/취소 처리 조사 | 40분 |
| 6 | 업그레이드 승인/거절 처리 조사 | 40분 |
| 7 | `docs/business-rules.md` 작성 및 검토 | 60분 |
총 예상 소요: 약 4시간
+339
View File
@@ -0,0 +1,339 @@
# chocoadmin 프로젝트 계획서
## 1. 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 서비스명 | chocoadmin |
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
---
## 2. 기술 스택
| 구분 | 기술 |
|---|---|
| 런타임 | Node.js (LTS 버전) |
| 프레임워크 | Next.js 15 (App Router) |
| 언어 | TypeScript |
| DB 클라이언트 | `mysql2` (MariaDB 호환) |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 환경변수 관리 | `.env.local` / `.env.production` |
---
## 3. CSS Framework 추천
관리자 화면은 주로 표(Table)와 폼(Form) 중심이므로, 빠른 개발과 유지보수성을 모두 고려한 3가지를 추천합니다.
### 3-1. Tailwind CSS + shadcn/ui ⭐ 추천
- **Tailwind CSS**: 유틸리티 클래스 기반으로 CSS 파일을 직접 작성할 일이 거의 없음
- **shadcn/ui**: Tailwind 기반의 복사-붙여넣기형 컴포넌트 라이브러리. Button, Table, Dialog, Form 등 관리자 UI에 필요한 컴포넌트가 이미 갖춰져 있음
- **장점**: Next.js App Router와 궁합이 뛰어남. 번들 사이즈가 작고, 컴포넌트 코드를 직접 소유하므로 커스터마이징이 자유로움
- **단점**: 초기 세팅이 다소 필요함
### 3-2. Ant Design (antd)
- React 생태계에서 오랫동안 검증된 전통적인 관리자 UI 라이브러리
- Table, Form, Modal, DatePicker 등 관리자 페이지에 필요한 컴포넌트가 모두 내장되어 있음
- **장점**: 별도 CSS 작업 없이 완성도 높은 UI를 빠르게 구성 가능
- **단점**: 번들 사이즈가 크고, 기본 디자인 톤에서 벗어나는 커스터마이징이 어려움
### 3-3. Mantine
- shadcn/ui보다 더 완결된 형태의 컴포넌트 세트 제공 (100개 이상)
- 자체 훅(hook) 라이브러리도 포함되어 있어 복잡한 상태 관리 없이 폼·테이블 구현 가능
- **장점**: 문서가 잘 정리되어 있고 TypeScript 지원이 뛰어남
- **단점**: antd처럼 디자인 자유도가 shadcn/ui보다 낮음
> **최종 추천: Tailwind CSS + shadcn/ui**
> Next.js 공식 생태계와 가장 잘 맞고, 장기적인 유지보수 측면에서 가장 유리합니다.
---
## 4. Table 라이브러리 추천
관리자 화면은 목록 조회, 검색, 정렬, 페이지네이션, 인라인 액션 버튼이 핵심이므로 아래 3가지를 추천합니다.
### 4-1. TanStack Table (React Table v8) ⭐ 추천
- Headless 방식(스타일 미포함)으로, Tailwind/shadcn과 조합해 완전한 커스터마이징 가능
- 정렬, 필터, 페이지네이션, 행 선택, 컬럼 고정 등 필요한 기능을 필요한 만큼만 추가
- **shadcn/ui DataTable**: TanStack Table + shadcn 조합의 공식 예제가 제공되어 빠른 시작 가능
- **장점**: 번들 사이즈 최소, 자유도 최대, Next.js App Router 완벽 호환
- **단점**: 기능을 직접 조립해야 하므로 초기 설정 비용이 있음
### 4-2. AG Grid (Community Edition)
- 대용량 데이터에 특화된 고성능 그리드 라이브러리
- 정렬, 필터, 그룹핑, 엑셀 내보내기, 인라인 편집 등 풍부한 기능이 내장
- 무료 Community Edition으로도 관리자 기능 구현에 충분
- **장점**: 기능이 가장 풍부하고 성능이 뛰어남
- **단점**: 번들 사이즈가 크고, 스타일 커스터마이징에 별도 CSS 작업이 필요
### 4-3. MUI DataGrid (Free tier)
- Material UI 기반의 데이터 그리드
- 정렬, 필터, 페이지네이션, 컬럼 재배치가 내장
- **장점**: Material UI 생태계와 자연스럽게 통합, 사용법이 직관적
- **단점**: Tailwind와 함께 사용 시 스타일 충돌 이슈가 발생할 수 있음. 고급 기능은 Pro 유료 플랜 필요
> **최종 추천: TanStack Table + shadcn/ui DataTable**
> shadcn/ui와 세트로 사용하면 코드 통일성이 높고, 필요한 기능만 붙일 수 있어 관리 부담이 적습니다.
---
## 5. 개발 환경 및 배포 환경
### 5-1. 개발 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS (DS920+, 20GB RAM) - Docker |
| DB (개발) | localhost MariaDB |
| 실행 방법 | `docker compose up` |
| 포트 | 3001 (기존 mouse-typing과 충돌 방지) |
### 5-2. 서비스 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS (DS920+) - Docker |
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
| 접근 방식 | NAS에서 AWS EC2 DB에 직접 연결 (보안그룹 인바운드 규칙에 NAS 외부 IP 허용 필요) |
### 5-3. docker-compose.yml 구성 계획
```yaml
# docker-compose.yml (개발용)
services:
chocoadmin:
build: .
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- DB_HOST=host.docker.internal
- DB_PORT=3306
- DB_NAME=chocomae
- DB_USER=...
- DB_PASS=...
volumes:
- .:/app
- /app/node_modules
- /app/.next
```
### 5-4. Dockerfile 계획
```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]
```
---
## 6. 데이터베이스 주요 테이블 정리
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
| 테이블 | 설명 | 주요 컬럼 |
|---|---|---|
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime` |
| `maestro_extension` | 마에스트로 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
| `maestro_upgrade` | 마에스트로 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
| `maestro_log` | 마에스트로 처리 이력 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
### AccountType 코드표
| 값 | 의미 |
|---|---|
| 1 | 20명 (1만원) |
| 2 | 50명 (2만원) |
| 3 | 100명 (3만원) |
| 4 | 500명 (4만원) |
| 5 | 1,000명 (5만원) |
### ActivateStatus 코드표 (maestro)
| 값 | 의미 |
|---|---|
| 0 | 미승인 |
| 1 | 체험 |
| 2 | 활성화 |
| 100 | 등록취소 |
### Status 코드표 (maestro_extension / maestro_upgrade)
| 값 | 의미 |
|---|---|
| 1 | 요청 |
| 2 | 취소 |
| 3 | 연장/업그레이드 적용 |
---
## 7. 주요 화면 목록 및 API 설계
### 7-1. 화면 구조 (Next.js App Router)
```
app/
├── (auth)/
│ └── login/ # 관리자 로그인
├── (admin)/
│ ├── layout.tsx # 공통 사이드바 + 헤더
│ ├── maestro/
│ │ ├── page.tsx # 마에스트로 전체 목록
│ │ └── [id]/
│ │ └── page.tsx # 마에스트로 상세 정보
│ ├── extension/
│ │ └── page.tsx # 연장 신청 목록 + 승인/취소
│ └── upgrade/
│ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
└── api/
├── auth/[...nextauth]/
├── maestro/
│ ├── route.ts # GET: 목록 조회
│ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
├── extension/
│ ├── route.ts # GET: 목록 조회
│ └── [id]/route.ts # PATCH: 승인/취소
└── upgrade/
├── route.ts # GET: 목록 조회
└── [id]/route.ts # PATCH: 승인/거절
```
### 7-2. 화면별 기능 명세
#### [1] 마에스트로 전체 목록 (`/maestro`)
- 전체 마에스트로 목록 표시
- 검색: 이름, 이메일
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
- 정렬: 가입일, 만료일
- 컬럼: ID / 이름 / 이메일 / 계정유형 / 상태 / 인원수 / 사용가능일 / 상세보기
#### [2] 마에스트로 상세 정보 (`/maestro/[id]`)
- 기본 정보 (이름, 이메일, 계정 유형, 상태, 만료일)
- 플레이어(학생) 목록
- 연장 신청 이력
- 업그레이드 신청 이력
- 관리자 처리 로그
#### [3] 연장 신청 목록 (`/extension`)
- 연장 신청 목록 표시 (기본: Status=1 요청 건만)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인버튼 / 취소버튼
- 승인 시: `maestro.ActivateStatus = 2`, `maestro.AvailableActivateDateTime` 1년 연장, `maestro_extension.Status = 3`
- 취소 시: `maestro_extension.Status = 2`
#### [4] 업그레이드 신청 목록 (`/upgrade`)
- 업그레이드 신청 목록 표시 (기본: Status=1 요청 건만)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인버튼 / 거절버튼
- 승인 시: `maestro.AccountType` 변경, `maestro_upgrade.Status = 3`
- 거절 시: `maestro_upgrade.Status = 2`
---
## 8. 개발 단계 계획
### Phase 1. 프로젝트 초기화 및 환경 구성
- [ ] Next.js 15 + TypeScript 프로젝트 생성
- [ ] Tailwind CSS + shadcn/ui 설치 및 설정
- [ ] TanStack Table 설치
- [ ] mysql2 설치 및 DB 연결 유틸리티 작성
- [ ] docker-compose.yml 작성 (개발용)
- [ ] `.env.local` 환경변수 구성
- [ ] NextAuth.js v5 설치 및 `admin` 테이블 기반 로그인 구현
### Phase 2. 공통 레이아웃 및 인증
- [ ] 로그인 페이지 (`/login`)
- [ ] 인증 미들웨어 (비로그인 시 `/login` 리다이렉트)
- [ ] 공통 레이아웃 (사이드바 네비게이션, 상단 헤더)
### Phase 3. 마에스트로 관리
- [ ] 마에스트로 전체 목록 페이지 + API
- [ ] 마에스트로 상세 정보 페이지 + API
### Phase 4. 연장 신청 관리
- [ ] 연장 신청 목록 페이지 + API
- [ ] 연장 승인 / 취소 기능 + API
### Phase 5. 업그레이드 신청 관리
- [ ] 업그레이드 신청 목록 페이지 + API
- [ ] 업그레이드 승인 / 거절 기능 + API
### Phase 6. 배포 환경 구성
- [ ] Dockerfile (프로덕션용) 작성
- [ ] docker-compose.prod.yml 작성 (Synology NAS용)
- [ ] AWS EC2 DB 보안그룹 인바운드 설정 (NAS 외부 IP 허용)
- [ ] Synology NAS Container Manager에서 배포 테스트
---
## 9. 디렉토리 구조 (목표)
```
chocoadmin/
├── app/
│ ├── (auth)/
│ │ └── login/
│ ├── (admin)/
│ │ ├── layout.tsx
│ │ ├── maestro/
│ │ ├── extension/
│ │ └── upgrade/
│ └── api/
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ ├── data-table/ # TanStack Table 공통 컴포넌트
│ └── layout/ # 사이드바, 헤더
├── lib/
│ ├── db.ts # mysql2 연결 풀
│ └── utils.ts
├── types/
│ └── index.ts # DB 타입 정의
├── docs/
│ └── db-reference/
│ └── schema.sql
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── .env.local # (gitignore)
└── .env.production # (gitignore)
```
---
## 10. 보안 고려사항
- 관리자 전용 서비스이므로 외부 노출을 최소화 (NAS 내부 포트 또는 VPN 기반 접근 권장)
- `admin.Password`는 해시 검증 방식으로 NextAuth 연동 (기존 암호화 방식 확인 필요)
- DB 연결 정보는 `.env` 파일로 관리하며 Git에 포함하지 않음
- AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS IP로만 허용
---
*작성일: 2026-05-26*
+501
View File
@@ -0,0 +1,501 @@
# chocoadmin 개발 작업 계획
## 1. 프로젝트 개요
`chocoadmin`은 기존 `chocomae` 웹 서비스의 관리자 페이지와 관리자 기능만 독립 분리하는 신규 관리자 웹 서비스이다.
- 기존 서비스명: `chocomae`
- 기존 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/mouse-typing`
- 신규 서비스명: `chocoadmin`
- 신규 서비스 개발 디렉토리: `/Users/jisangs/work/jinaju/git/web/chocoadmin`
- 개발 목표: 기존 `mouse-typing` 서비스의 관리자 기능을 Next.js 기반 독립 서비스로 재구성
- 기존 운영 환경: AWS EC2 `t3a.medium` 인스턴스 1개
- 신규 개발/운영 플랫폼: Synology NAS Docker
- 보유 NAS: Synology DS920+, RAM 20GB
## 2. 핵심 개발 방향
1. 기존 `chocomae` DB를 그대로 사용한다.
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
3. 운영 DB 스키마를 임의 변경하지 않고, 기존 테이블과 상태값을 우선 존중한다.
4. 관리자 액션은 반드시 서버 사이드에서 권한 검증, 트랜잭션, 로그 기록을 거친다.
5. Synology NAS Docker에서 개발과 운영을 모두 재현 가능하게 구성한다.
## 3. 기술 스택 제안
### 기본 스택
- Runtime: Node.js LTS
- Framework: Next.js App Router
- Language: TypeScript
- Database: MariaDB
- DB Access: Prisma 또는 Drizzle ORM
- Auth: NextAuth/Auth.js 또는 자체 세션 기반 인증
- Validation: Zod
- Package Manager: pnpm
- Container: Docker, Docker Compose
### DB 접근 방식 추천
1. Prisma
- 장점: 타입 안정성, 마이그레이션/스키마 관리 경험이 좋고 개발 속도가 빠름
- 주의: 기존 MariaDB 스키마를 introspection해서 사용하는 방식이 적합함
2. Drizzle ORM
- 장점: SQL에 가까운 제어, 가벼움, 서버 액션/API Route와 잘 맞음
- 주의: 기존 DB 테이블 정의를 직접 코드로 옮기는 작업이 필요함
권장안: 초기 개발 생산성과 유지보수를 고려하면 Prisma를 우선 검토하고, 기존 DB 쿼리를 세밀하게 제어해야 하는 구간만 raw SQL을 병행한다.
## 4. CSS Framework 추천
관리자 서비스는 표, 검색, 필터, 상세 보기, 승인/거절 액션이 중심이므로 화려한 랜딩 페이지보다 유지보수성과 일관성이 중요하다.
### 1순위: Tailwind CSS + shadcn/ui
- 장점: Next.js와 궁합이 좋고, 관리자 화면에 필요한 버튼, 입력, 다이얼로그, 탭, 폼 구성이 빠름
- 장점: 컴포넌트 코드를 프로젝트 안에서 직접 소유하므로 장기 유지보수에 유리함
- 단점: Tailwind 클래스에 익숙해지는 시간이 필요함
- 추천 이유: `chocoadmin`처럼 작은 팀/개인 프로젝트에서 빠르게 만들고 나중에 고치기 좋음
### 2순위: Mantine
- 장점: 컴포넌트가 풍부하고 Form, Modal, Table 주변 UI를 빠르게 만들기 좋음
- 장점: 테마 시스템과 훅이 잘 갖춰져 있음
- 단점: 디자인을 많이 커스터마이징하면 프레임워크 스타일에 묶일 수 있음
- 추천 이유: 관리자 도구를 빠르게 안정적인 UI로 완성하기 좋음
### 3순위: Chakra UI
- 장점: 접근성, 컴포넌트 API, 테마 관리가 안정적임
- 장점: Next.js 프로젝트에서 사용 사례가 많음
- 단점: 대량 데이터 테이블은 별도 라이브러리 조합이 필요함
- 추천 이유: 익숙한 컴포넌트 기반 개발을 선호할 때 적합함
최종 권장안: `Tailwind CSS + shadcn/ui`를 기본 UI 프레임워크로 사용한다.
## 5. Table 라이브러리 추천
관리자 화면은 마에스트로 목록, 연장 신청, 업그레이드 신청처럼 대부분 표 기반으로 운영되므로 정렬, 필터, 검색, 페이지네이션, 행 선택, 컬럼 표시 제어, 액션 버튼 확장이 쉬워야 한다.
### 1순위: TanStack Table
- 장점: headless 방식이라 UI 프레임워크와 자유롭게 결합 가능
- 장점: 서버 사이드 페이지네이션, 정렬, 필터링을 구현하기 좋음
- 장점: shadcn/ui의 Data Table 패턴과 잘 맞음
- 단점: 직접 조립해야 하는 부분이 있어 초기 구현량이 있음
- 추천 이유: 관리자 기능이 늘어날 가능성이 높은 `chocoadmin`에 가장 유연함
### 2순위: AG Grid Community
- 장점: 엑셀 같은 고급 그리드 기능이 강함
- 장점: 컬럼 리사이즈, 고정 컬럼, 복잡한 필터 등 고급 기능이 풍부함
- 단점: UI가 무겁고, 일부 기능은 Enterprise 라이선스가 필요함
- 추천 이유: 향후 대량 데이터 운영 도구가 필요해질 때 적합함
### 3순위: Mantine React Table
- 장점: Mantine을 CSS/UI 프레임워크로 선택할 경우 통합 경험이 좋음
- 장점: TanStack Table 기반이라 기능 확장성이 좋음
- 단점: Mantine 생태계에 묶임
- 추천 이유: Mantine을 선택하는 경우 가장 빠르게 관리자 표를 만들 수 있음
최종 권장안: `TanStack Table + shadcn/ui Data Table` 조합을 사용한다.
## 6. DB 기준 관리자 대상 테이블
스키마 파일: `/Users/jisangs/work/jinaju/git/web/chocoadmin/docs/db-reference/schema.sql`
### 주요 테이블
- `admin`: 관리자 계정
- `maestro`: 마에스트로 계정
- `maestro_extension`: 마에스트로 연장 신청
- `maestro_upgrade`: 마에스트로 업그레이드 신청
- `maestro_log`: 마에스트로 관련 처리 로그
- `player`: 마에스트로에 속한 학생/플레이어
### 주요 상태값
`maestro.ActivateStatus`
- `0`: 미승인
- `1`: 체험
- `2`: 활성화
- `100`: 등록취소
`maestro.AccountType`
- `1`: 20명
- `2`: 50명
- `3`: 100명
- `4`: 500명
- `5`: 1000명
`maestro_extension.Status`
- `1`: 요청
- `2`: 취소
- `3`: 연장 적용
`maestro_upgrade.Status`
- `1`: 요청
- `2`: 취소
- `3`: 업그레이드 적용
## 7. 주요 관리자 화면 계획
### 7.1 공통 레이아웃
- 로그인 화면
- 좌측 사이드바
- 상단 관리자 정보 영역
- 공통 검색/필터 패턴
- 공통 확인 다이얼로그
- 처리 성공/실패 토스트
- 접근 권한 없는 경우 안내 화면
### 7.2 마에스트로 전체 목록
목표: 전체 마에스트로 계정을 검색, 필터링, 확인할 수 있는 운영 화면을 만든다.
주요 기능:
- 전체 목록 조회
- 이름, 이메일, `MaestroID` 검색
- 계정 종류 필터
- 활성 상태 필터
- 가입/약관 동의/사용 가능 기간 기준 정렬
- 학생 수 표시
- 상세 화면 이동
표 주요 컬럼:
- `MaestroID`
- `Name`
- `Email`
- `AccountType`
- `ActivateStatus`
- `PlayerCount`
- `AvailableActivateDateTime`
- `AcceptClausesDateTime`
- `AllowEditEnterCode`
### 7.3 마에스트로 상세 정보 보기
목표: 특정 마에스트로의 계정 상태와 관련 신청 이력을 한 화면에서 확인한다.
주요 기능:
- 기본 계정 정보
- 현재 계정 종류/활성 상태 표시
- 학생 수 및 플레이어 목록 요약
- 최근 연장 신청 이력
- 최근 업그레이드 신청 이력
- 처리 로그 표시
확장 후보 기능:
- 계정 상태 수동 변경
- 학생 목록 상세 조회
- 입장번호 변경 허용 여부 수정
단, 확장 후보 기능은 기존 관리자 기능과 운영 정책 확인 후 별도 구현한다.
### 7.4 마에스트로 연장 신청 목록
목표: `maestro_extension`의 연장 요청을 검토하고 승인 또는 취소 처리한다.
주요 기능:
- 연장 신청 목록 조회
- 요청 상태별 필터
- 요청일 기준 정렬
- 마에스트로 정보 함께 표시
- 연장 승인
- 연장 취소
- 처리 후 `maestro_log` 기록
승인 처리 초안:
1. `maestro_extension.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_extension.Status``3`으로 변경한다.
4. `maestro.AccountType`, `AvailableActivateDateTime`, `ActivateStatus` 갱신 규칙을 기존 서비스에서 확인해 동일하게 적용한다.
5. `maestro_log`에 처리 기록을 남긴다.
6. 트랜잭션을 커밋한다.
취소 처리 초안:
1. `maestro_extension.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_extension.Status``2`로 변경한다.
4. `maestro_log`에 취소 기록을 남긴다.
5. 트랜잭션을 커밋한다.
### 7.5 마에스트로 업그레이드 신청 목록
목표: `maestro_upgrade`의 업그레이드 요청을 검토하고 승인 또는 거절 처리한다.
주요 기능:
- 업그레이드 신청 목록 조회
- 요청 상태별 필터
- 현재 계정 종류와 요청 계정 종류 비교 표시
- 요청일 기준 정렬
- 업그레이드 승인
- 업그레이드 거절
- 처리 후 `maestro_log` 기록
승인 처리 초안:
1. `maestro_upgrade.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_upgrade.Status``3`으로 변경한다.
4. `maestro.AccountType``RequestedAccountType`으로 변경한다.
5. 필요 시 `PlayerCount` 정책을 기존 서비스에서 확인해 반영한다.
6. `maestro_log`에 처리 기록을 남긴다.
7. 트랜잭션을 커밋한다.
거절 처리 초안:
1. `maestro_upgrade.Status = 1`인지 확인한다.
2. 트랜잭션을 시작한다.
3. `maestro_upgrade.Status``2`로 변경한다.
4. `maestro_log`에 거절 기록을 남긴다.
5. 트랜잭션을 커밋한다.
## 8. 애플리케이션 구조 제안
```text
chocoadmin/
app/
login/
maestros/
page.tsx
[maestroId]/
page.tsx
extension-requests/
page.tsx
upgrade-requests/
page.tsx
api/
components/
layout/
data-table/
forms/
dialogs/
lib/
auth/
db/
validators/
constants/
docs/
db-reference/
schema.sql
project-plan.md
prisma/
schema.prisma
docker/
docker-compose.yml
Dockerfile
```
## 9. Docker 개발/운영 계획
### 개발 환경
- Synology NAS Docker에서 Next.js 컨테이너 실행
- 개발 DB는 `localhost` MariaDB 사용
- `.env.local`로 DB 접속 정보 관리
- 개발 DB는 운영 DB 덤프에서 민감 정보를 제거한 샘플 데이터 사용 권장
### 운영 환경
- Synology NAS Docker에서 `chocoadmin` 컨테이너 실행
- 운영 DB는 기존 AWS EC2 MariaDB에 접속
- AWS 보안 그룹에서 Synology NAS 공인 IP만 MariaDB 접속 허용
- DB 계정은 관리자 서비스 전용 계정으로 분리
- 최소 권한 원칙 적용
- 운영 배포 시 환경변수는 Synology Container Manager 또는 `.env.production`으로 관리
### Docker 구성 초안
- `Dockerfile`: Next.js production build
- `docker-compose.yml`: app service 정의
- `healthcheck`: HTTP 상태 확인 엔드포인트 사용
- `restart: unless-stopped`
- 로그는 Docker 기본 로그 또는 Synology 로그 수집 방식 사용
## 10. 보안 계획
1. 관리자 로그인은 서버 세션 기반으로 구현한다.
2. 관리자 비밀번호 저장 방식은 기존 `admin.Password` 구조를 확인한 뒤 개선 가능성을 검토한다.
3. 운영 DB 접속 정보는 저장소에 커밋하지 않는다.
4. 승인/취소/거절 같은 변경 액션은 CSRF 또는 서버 액션 보호를 적용한다.
5. 관리자 액션은 `maestro_log`에 기록한다.
6. 운영 DB 직접 수정 기능은 최소화한다.
7. 승인/거절 API는 중복 처리 방지를 위해 현재 상태를 조건으로 검사한다.
8. 모든 변경 작업은 트랜잭션으로 처리한다.
## 11. 개발 단계별 작업 계획
### Phase 0. 기존 관리자 기능 조사
- 기존 `mouse-typing` 코드에서 관리자 라우트와 처리 로직 조사
- 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인
- 업그레이드 승인 시 `PlayerCount``AccountType` 처리 방식 확인
- 기존 `admin.Password` 검증 방식 확인
- 기존 로그 기록 형식 확인
완료 기준:
- 기존 관리자 처리 규칙을 문서화한다.
- 신규 서비스에서 재현해야 할 비즈니스 규칙을 확정한다.
### Phase 1. 프로젝트 초기화
- Next.js + TypeScript 프로젝트 생성
- ESLint, Prettier 설정
- Tailwind CSS + shadcn/ui 설정
- 기본 레이아웃 구성
- 환경변수 구조 정의
- Dockerfile, docker-compose 초안 작성
완료 기준:
- 로컬 또는 Synology Docker에서 기본 페이지가 실행된다.
### Phase 2. DB 연결 및 타입 구성
- MariaDB 연결 설정
- Prisma introspection 또는 Drizzle schema 작성
- DB 연결 헬퍼 구현
- 상태값 상수화
- 공통 날짜/계정 종류 표시 유틸 작성
완료 기준:
- 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회가 가능하다.
### Phase 3. 인증과 관리자 세션
- 로그인 화면 구현
- 관리자 계정 검증
- 세션 생성/만료 처리
- 보호 라우트 처리
- 로그아웃 구현
완료 기준:
- 로그인하지 않은 사용자는 관리자 화면에 접근할 수 없다.
- 로그인한 관리자만 목록과 상세 화면에 접근할 수 있다.
### Phase 4. 마에스트로 목록/상세 화면
- 마에스트로 목록 API 또는 서버 액션 구현
- TanStack Table 기반 목록 화면 구현
- 검색, 필터, 정렬, 페이지네이션 구현
- 상세 화면 구현
- 상세 화면에서 연장/업그레이드/로그 요약 표시
완료 기준:
- 운영자가 마에스트로를 빠르게 검색하고 상세 정보를 확인할 수 있다.
### Phase 5. 연장 신청 관리
- 연장 신청 목록 구현
- 상태 필터와 검색 구현
- 승인/취소 확인 다이얼로그 구현
- 승인/취소 서버 액션 구현
- 트랜잭션과 로그 기록 적용
완료 기준:
- 요청 상태의 연장 신청을 승인 또는 취소할 수 있다.
- 중복 처리와 이미 처리된 건의 재처리가 방지된다.
### Phase 6. 업그레이드 신청 관리
- 업그레이드 신청 목록 구현
- 현재/요청 계정 비교 표시
- 승인/거절 확인 다이얼로그 구현
- 승인/거절 서버 액션 구현
- 트랜잭션과 로그 기록 적용
완료 기준:
- 요청 상태의 업그레이드 신청을 승인 또는 거절할 수 있다.
- 처리 결과가 마에스트로 계정에 정확히 반영된다.
### Phase 7. 운영 배포 준비
- Synology NAS Docker Compose 운영 설정 작성
- 운영 환경변수 정리
- AWS EC2 MariaDB 접근 보안 그룹 설정 가이드 작성
- 백업/롤백 절차 작성
- 관리자 액션 테스트 체크리스트 작성
완료 기준:
- Synology NAS에서 운영 컨테이너를 실행할 수 있다.
- 운영 DB 연결 전 점검 항목이 문서화된다.
### Phase 8. 검증 및 안정화
- 주요 화면 수동 테스트
- 승인/취소/거절 트랜잭션 테스트
- 권한 없는 접근 테스트
- Docker 재시작 테스트
- 운영 DB read-only 리허설 후 실제 변경 기능 활성화
완료 기준:
- 핵심 관리자 업무가 신규 서비스에서 안정적으로 수행된다.
## 12. 우선 확인이 필요한 질문
1. 기존 `mouse-typing` 관리자 코드에서 연장 승인 시 정확히 며칠/몇 개월을 연장하는가?
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
3. `admin.Password`는 평문, 해시, 자체 암호화 중 어떤 방식인가?
4. 운영 MariaDB가 외부 접속을 허용할 수 있는 네트워크 구조인가?
5. 관리자 계정은 기존 `admin` 테이블을 그대로 사용할 것인가, 신규 인증 테이블을 둘 것인가?
6. 운영 초기에 읽기 전용 모드로 먼저 배포한 뒤 변경 기능을 켤 것인가?
## 13. 추천 MVP 범위
1차 MVP는 아래 기능까지만 포함한다.
- 관리자 로그인
- 마에스트로 전체 목록
- 마에스트로 상세 정보 보기
- 연장 신청 목록
- 연장 승인/취소
- 업그레이드 신청 목록
- 업그레이드 승인/거절
- 관리자 액션 로그 기록
- Docker 기반 실행
MVP 이후 확장 후보:
- 관리자 계정 관리
- 마에스트로 상태 직접 변경
- 학생 목록 상세 관리
- 데이터 내보내기 CSV
- 처리 통계 대시보드
- 감사 로그 전용 화면
## 14. 최종 추천 조합
- Framework: Next.js App Router
- Language: TypeScript
- UI/CSS: Tailwind CSS + shadcn/ui
- Table: TanStack Table
- DB: MariaDB 기존 `chocomae` DB
- ORM: Prisma 우선 검토
- Validation: Zod
- Runtime/Deploy: Docker on Synology NAS
- 운영 DB 연결: AWS EC2 MariaDB에 제한된 IP와 전용 계정으로 접속
+409
View File
@@ -0,0 +1,409 @@
# chocoadmin 프로젝트 계획서
## 1. 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 서비스명 | chocoadmin |
| 목적 | chocomae 웹 서비스(mouse-typing)의 관리자 기능을 독립 웹 서비스로 분리 |
| 기존 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/mouse-typing` |
| 신규 서비스 경로 | `/Users/jisangs/work/jinaju/git/web/chocoadmin` |
| 기존 운영 환경 | AWS EC2 t3a.medium 인스턴스 1개 |
| 사용 DB | 기존 chocomae DB (MariaDB 10.11.13) 그대로 사용 |
---
## 2. 핵심 개발 방향
1. 기존 `chocomae` DB를 그대로 사용한다. DB 스키마는 임의로 변경하지 않는다.
2. 관리자 UI와 서버 로직만 신규 Next.js 서비스로 분리한다.
3. 기존 테이블 구조와 상태값을 그대로 존중하고, 기존 서비스의 비즈니스 규칙을 먼저 파악한 뒤 동일하게 구현한다.
4. 승인·취소·거절 같은 변경 액션은 반드시 서버 사이드에서 권한 검증 → 트랜잭션 → 로그 기록 순서로 처리한다.
5. 중복 처리 방지를 위해 모든 변경 API는 현재 상태를 조건으로 먼저 검사한다.
---
## 3. 기술 스택
| 구분 | 기술 |
|---|---|
| 런타임 | Node.js LTS |
| 프레임워크 | Next.js 16 (App Router) |
| 언어 | TypeScript |
| 패키지 매니저 | pnpm |
| UI / CSS | Tailwind CSS + shadcn/ui |
| Table | TanStack Table (React Table v8) |
| DB 클라이언트 | mysql2 (MariaDB 호환) |
| ORM | Prisma (기존 DB introspection 방식으로 활용) |
| 유효성 검증 | Zod |
| 인증 | NextAuth.js v5 (기존 `admin` 테이블 활용) |
| 환경변수 관리 | `.env.local` / `.env.production` |
### Prisma vs Drizzle ORM
두 가지 모두 MariaDB를 지원하나, 이 프로젝트에서는 **Prisma**를 우선 사용한다.
- 기존 DB를 `prisma db pull`(introspection)로 그대로 가져올 수 있어 초기 세팅이 빠름
- TypeScript 타입 자동 생성으로 컬럼명 오타를 컴파일 단계에서 잡을 수 있음
- 세밀한 SQL 제어가 필요한 구간은 `$queryRaw` / `$executeRaw`로 raw SQL 병행 가능
---
## 4. 개발 환경 및 배포 환경
### 4-1. 개발 환경
| 항목 | 내용 |
|---|---|
| 하드웨어 | 맥미니 |
| IDE | IntelliJ IDEA |
| 실행 방법 | IntelliJ 터미널에서 `pnpm dev` |
| 포트 | 3001 (기존 mouse-typing과 충돌 방지) |
| DB (개발) | 맥미니 로컬 MariaDB (`localhost:3306`) |
| 환경변수 | `.env.local` |
### 4-2. 배포(서비스) 환경
| 항목 | 내용 |
|---|---|
| 플랫폼 | Synology NAS DS920+ (RAM 20GB) — Docker |
| 실행 방법 | Synology Container Manager (`docker compose up`) |
| DB (서비스) | AWS EC2 MariaDB (chocomae 서비스와 동일 인스턴스) |
| DB 접근 | AWS 보안그룹에서 NAS 외부 IP만 3306 포트 허용 |
| DB 계정 | chocoadmin 전용 최소 권한 계정으로 분리 |
| 환경변수 | `.env.production` 또는 Container Manager 환경변수 설정 |
### 4-3. Dockerfile / docker-compose (배포용)
```dockerfile
# Dockerfile
FROM node:22-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
CMD ["pnpm", "start"]
```
```yaml
# docker-compose.yml
services:
chocoadmin:
build: .
ports:
- "3001:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://USER:PASS@AWS_EC2_HOST:3306/chocomae
- NEXTAUTH_SECRET=...
- NEXTAUTH_URL=http://NAS_IP:3001
restart: unless-stopped
```
---
## 5. 데이터베이스 주요 테이블
스키마: [`docs/db-reference/schema.sql`](./db-reference/schema.sql)
| 테이블 | 설명 | 주요 컬럼 |
|---|---|---|
| `maestro` | 마에스트로(강사) 계정 | `MaestroID`, `Name`, `Email`, `AccountType`, `ActivateStatus`, `AvailableActivateDateTime`, `PlayerCount` |
| `maestro_extension` | 연장 신청 | `MaestroExtensionID`, `MaestroID`, `AccountType`, `RequestedDateTime`, `Status` |
| `maestro_upgrade` | 업그레이드 신청 | `MaestroUpgradeID`, `MaestroID`, `RegisteredAccountType`, `RequestedAccountType`, `RequestedDateTime`, `Status` |
| `maestro_log` | 처리 이력 로그 | `MaestroLogID`, `MaestroID`, `Type`, `LogDateTime`, `Remark` |
| `admin` | 관리자 계정 | `AdminID`, `Name`, `Password`, `Email`, `AccountType`, `ActivateStatus` |
| `player` | 학생 계정 | `PlayerID`, `MaestroID`, `Name`, `EnterCode` |
### 상태값 코드표
**`maestro.AccountType`**
| 값 | 의미 |
|---|---|
| 1 | 20명 (1만원) |
| 2 | 50명 (2만원) |
| 3 | 100명 (3만원) |
| 4 | 500명 (4만원) |
| 5 | 1,000명 (5만원) |
**`maestro.ActivateStatus`**
| 값 | 의미 |
|---|---|
| 0 | 미승인 |
| 1 | 체험 |
| 2 | 활성화 |
| 100 | 등록취소 |
**`maestro_extension.Status` / `maestro_upgrade.Status`**
| 값 | 의미 |
|---|---|
| 1 | 요청 |
| 2 | 취소/거절 |
| 3 | 적용 완료 |
---
## 6. 애플리케이션 구조
```
chocoadmin/
├── app/
│ ├── (auth)/
│ │ └── login/
│ │ └── page.tsx # 관리자 로그인
│ ├── (admin)/
│ │ ├── layout.tsx # 공통 사이드바 + 헤더
│ │ ├── maestros/
│ │ │ ├── page.tsx # 마에스트로 전체 목록
│ │ │ └── [maestroId]/
│ │ │ └── page.tsx # 마에스트로 상세 정보
│ │ ├── extension-requests/
│ │ │ └── page.tsx # 연장 신청 목록 + 승인/취소
│ │ └── upgrade-requests/
│ │ └── page.tsx # 업그레이드 신청 목록 + 승인/거절
│ └── api/
│ ├── auth/[...nextauth]/
│ ├── maestros/
│ │ ├── route.ts # GET: 목록
│ │ └── [id]/route.ts # GET: 상세, PATCH: 상태 변경
│ ├── extension-requests/
│ │ └── [id]/route.ts # PATCH: 승인/취소
│ └── upgrade-requests/
│ └── [id]/route.ts # PATCH: 승인/거절
├── components/
│ ├── ui/ # shadcn/ui 컴포넌트
│ ├── data-table/ # TanStack Table 공통 컴포넌트
│ ├── layout/ # 사이드바, 헤더
│ ├── forms/
│ └── dialogs/ # 승인/취소 확인 다이얼로그
├── lib/
│ ├── db.ts # Prisma Client 싱글톤
│ ├── auth.ts # NextAuth 설정
│ ├── validators/ # Zod 스키마
│ └── constants.ts # 상태값 상수
├── types/
│ └── index.ts
├── prisma/
│ └── schema.prisma # DB introspection 결과
├── docs/
│ └── db-reference/
│ └── schema.sql
├── .env.local # gitignore
├── .env.production # gitignore
├── Dockerfile
└── docker-compose.yml
```
---
## 7. 주요 화면 기능 명세
### 7-1. 공통 레이아웃
- 로그인 화면 (미인증 시 `/login` 리다이렉트)
- 좌측 사이드바 네비게이션
- 공통 확인 다이얼로그 (승인/취소/거절 전 확인)
- 처리 성공/실패 토스트 알림
### 7-2. 마에스트로 전체 목록 (`/maestros`)
- 전체 목록 조회 (서버 사이드 페이지네이션)
- 검색: 이름, 이메일, MaestroID
- 필터: ActivateStatus (미승인 / 체험 / 활성화 / 등록취소)
- 필터: AccountType (20명 / 50명 / 100명 / 500명 / 1,000명)
- 정렬: 가입일, 사용 가능일
- 컬럼: ID / 이름 / 이메일 / 계정 유형 / 상태 / 학생 수 / 사용 가능일 / 상세보기
### 7-3. 마에스트로 상세 정보 (`/maestros/[maestroId]`)
- 기본 계정 정보 (이름, 이메일, 계정 유형, 상태, 만료일, 약관 동의일)
- 플레이어(학생) 목록 요약
- 연장 신청 이력
- 업그레이드 신청 이력
- 관리자 처리 로그 (`maestro_log`)
### 7-4. 연장 신청 목록 (`/extension-requests`)
- 연장 신청 목록 조회 (기본 필터: Status=1 요청 건)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 계정유형 / 신청일시 / 상태 / 승인 / 취소
**승인 처리 순서:**
1. `maestro_extension.Status = 1` 인지 확인 (중복 처리 방지)
2. 트랜잭션 시작
3. `maestro_extension.Status → 3`
4. `maestro.AvailableActivateDateTime` 연장, `maestro.ActivateStatus → 2` (기존 서비스 로직 확인 후 동일 적용)
5. `maestro_log` 기록
6. 트랜잭션 커밋
**취소 처리 순서:**
1. `maestro_extension.Status = 1` 인지 확인
2. 트랜잭션 시작
3. `maestro_extension.Status → 2`
4. `maestro_log` 기록
5. 트랜잭션 커밋
### 7-5. 업그레이드 신청 목록 (`/upgrade-requests`)
- 업그레이드 신청 목록 조회 (기본 필터: Status=1 요청 건)
- 필터: 상태 (요청 / 취소 / 적용)
- 컬럼: 신청ID / 마에스트로명 / 현재등급 / 요청등급 / 신청일시 / 상태 / 승인 / 거절
**승인 처리 순서:**
1. `maestro_upgrade.Status = 1` 인지 확인 (중복 처리 방지)
2. 트랜잭션 시작
3. `maestro_upgrade.Status → 3`
4. `maestro.AccountType → RequestedAccountType` 변경 (PlayerCount 갱신 정책은 기존 서비스 확인 후 동일 적용)
5. `maestro_log` 기록
6. 트랜잭션 커밋
**거절 처리 순서:**
1. `maestro_upgrade.Status = 1` 인지 확인
2. 트랜잭션 시작
3. `maestro_upgrade.Status → 2`
4. `maestro_log` 기록
5. 트랜잭션 커밋
---
## 8. 보안 고려사항
1. 관리자 로그인은 NextAuth 서버 세션 기반으로 구현한다.
2. 기존 `admin.Password` 저장 방식(평문/해시/자체 암호화)을 먼저 확인한 뒤 동일 방식 또는 해시 업그레이드를 결정한다.
3. 승인·취소·거절 액션은 Next.js Server Action 또는 API Route에서 CSRF 보호를 적용한다.
4. DB 연결 정보는 `.env` 파일로 관리하며 Git에 커밋하지 않는다.
5. 배포 DB 계정은 chocoadmin 전용 계정으로 분리하고 최소 권한(SELECT + 필요한 테이블만 UPDATE)을 적용한다.
6. AWS EC2 보안그룹에서 MariaDB 포트(3306)를 NAS 외부 IP로만 허용한다.
7. 관리자 전용 서비스이므로 외부에 직접 노출하지 않고 VPN 또는 NAS 내부 포트로 접근하는 방식을 권장한다.
---
## 9. 개발 단계 계획
### Phase 0. 기존 관리자 코드 조사
> 기존 서비스 비즈니스 규칙을 파악해 재현 실수를 방지한다.
- [x] `mouse-typing` 관리자 라우트와 처리 로직 분석 (`src/web/admin/`, `src/web/server/admin/`)
- [x] 연장 승인 시 `AvailableActivateDateTime` 계산 방식 확인 (몇 개월/년 연장인지)
- [x] 업그레이드 승인 시 `PlayerCount` 갱신 여부 확인
- [x] `admin.Password` 검증 방식 확인 (평문 / MD5 / bcrypt 등)
- [x] `maestro_log` 기록 형식(`Type`, `Remark`) 확인
- [x] 조사 결과를 `docs/business-rules.md`에 문서화
### Phase 1. 프로젝트 초기화
- [x] `pnpm create next-app` (Next.js 16, TypeScript, App Router, Tailwind CSS)
- [x] shadcn/ui 초기화 (`pnpm dlx shadcn@latest init`)
- [x] TanStack Table 설치 (`pnpm add @tanstack/react-table`)
- [x] Prisma 설치 및 MariaDB introspection (`pnpm add prisma`, `prisma db pull`)
- [x] Zod 설치 (`pnpm add zod`)
- [x] NextAuth.js v5 설치
- [x] ESLint / Prettier 설정
- [x] `.env.local` 환경변수 구조 정의
- [x] IntelliJ에서 `pnpm dev` 실행 확인
### Phase 2. DB 연결 및 타입 구성
- [x] Prisma Client 싱글톤 (`lib/db.ts`)
- [x] 상태값 상수 정의 (`lib/constants.ts`)
- [x] 공통 유틸 작성 (날짜 포맷, AccountType 레이블 변환 등)
- [x] 개발 DB에서 `maestro`, `maestro_extension`, `maestro_upgrade` 조회 확인
### Phase 3. 인증 및 공통 레이아웃
- [x] 로그인 페이지 (`/login`)
- [x] NextAuth Credentials Provider — `admin` 테이블 기반 검증
- [x] 인증 프록시 (`proxy.ts`) — 비로그인 시 `/login` 리다이렉트
- [x] 공통 레이아웃 (`(admin)/layout.tsx`) — 사이드바, 헤더
- [x] 로그아웃 기능
### Phase 4. 마에스트로 목록 및 상세
- [x] 마에스트로 목록 API (`GET /api/maestros`) — 검색·필터·페이지네이션 포함
- [x] TanStack Table 기반 목록 화면
- [x] 마에스트로 상세 API (`GET /api/maestros/[id]`)
- [x] 상세 화면 — 연장/업그레이드 이력, 학생 목록 요약, 처리 로그
### Phase 5. 연장 신청 관리
- [x] 연장 신청 목록 API + 화면
- [x] 승인/취소 확인 다이얼로그
- [x] 승인 API (`PATCH /api/extension-requests/[id]`) — 트랜잭션 + maestro_log
- [x] 취소 API — 트랜잭션 + maestro_log
### Phase 6. 업그레이드 신청 관리
- [x] 업그레이드 신청 목록 API + 화면
- [x] 승인/거절 확인 다이얼로그
- [x] 승인 API (`PATCH /api/upgrade-requests/[id]`) — 트랜잭션 + maestro_log
- [x] 거절 API — 트랜잭션 + maestro_log
### Phase 7. 배포 환경 구성
- [x] Dockerfile (프로덕션용 pnpm 빌드)
- [x] `docker-compose.yml` (Synology NAS Container Manager용)
- [ ] AWS EC2 보안그룹 인바운드 설정 (NAS IP → 3306 허용) — `docs/deployment.md`에 절차 문서화
- [ ] Synology NAS에서 컨테이너 실행 테스트 — `docs/deployment.md`에 절차 문서화
- [ ] 운영 DB read-only 리허설 후 변경 기능 활성화 — `docs/deployment.md`에 절차 문서화
### Phase 8. 검증 및 안정화
- [ ] 승인/취소/거절 트랜잭션 시나리오 테스트
- [ ] 중복 처리 방지 (이미 처리된 건 재처리 시 에러 확인)
- [ ] 비인증 접근 차단 확인
- [ ] Docker 재시작 후 정상 동작 확인
---
## 10. MVP 범위
**1차 MVP (위 Phase 0~7):**
- 관리자 로그인
- 마에스트로 전체 목록 및 상세 정보 보기
- 연장 신청 목록 / 승인 / 취소
- 업그레이드 신청 목록 / 승인 / 거절
- 관리자 액션 로그 기록
- Docker 기반 NAS 배포
**MVP 이후 확장 후보:**
- 마에스트로 상태 직접 변경
- 학생 목록 상세 관리
- 데이터 내보내기 (CSV)
- 처리 통계 대시보드
- 감사 로그 전용 화면
- 관리자 계정 관리
---
## 11. 구현 전 확인이 필요한 사항
기존 `mouse-typing` 서비스 코드(`src/web/server/admin/`)에서 반드시 확인해야 할 비즈니스 규칙들이다.
1. 연장 승인 시 `AvailableActivateDateTime`을 정확히 얼마나 연장하는가? (1년? 계정 유형별 다름?)
: 계정 유형별로 차이 없고, 1년 연장
2. 업그레이드 승인 시 `PlayerCount`를 즉시 변경하는가, 아니면 `AccountType`만 변경하는가?
: AccountType만 변경
3. `admin.Password`는 어떤 방식으로 저장/검증되는가? (평문, MD5, bcrypt 등)
: MariaDB의 Password() 함수를 이용해서 저장/검증됨
4. `maestro_log``Type` 컬럼에 어떤 값들이 사용되는가? (`Remark` 형식 포함)
: 다음과 같은 값들이 사용됨
* add_maestro
* update_maestro
* add_maestro
* register_maestro
* update_maestro_name
* extension_maestro
* upgrade_maestro
* update_maestro_email
5. 운영 MariaDB가 외부 IP 접속을 허용할 수 있는 네트워크 구조인가?
: 외부 IP 접속을 허용하고 있다
---
*작성일: 2026-05-26*
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+81
View File
@@ -0,0 +1,81 @@
export const ACCOUNT_TYPES = {
BASIC_20: 1,
STANDARD_50: 2,
PRO_100: 3,
SCHOOL_500: 4,
SCHOOL_1000: 5,
STUDENT_TRIAL: 100,
MAESTRO_TRIAL: 101,
} as const;
export const ACCOUNT_TYPE_LABELS = {
[ACCOUNT_TYPES.BASIC_20]: "20명 (1만원)",
[ACCOUNT_TYPES.STANDARD_50]: "50명 (2만원)",
[ACCOUNT_TYPES.PRO_100]: "100명 (3만원)",
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (4만원)",
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (5만원)",
[ACCOUNT_TYPES.STUDENT_TRIAL]: "학생체험",
[ACCOUNT_TYPES.MAESTRO_TRIAL]: "마에체험",
} as const;
export const TRIAL_ACCOUNT_TYPE_LABELS = {
[ACCOUNT_TYPES.BASIC_20]: "20명 (0원)",
[ACCOUNT_TYPES.STANDARD_50]: "50명 (0원)",
[ACCOUNT_TYPES.PRO_100]: "100명 (0원)",
[ACCOUNT_TYPES.SCHOOL_500]: "500명 (0원)",
[ACCOUNT_TYPES.SCHOOL_1000]: "1,000명 (0원)",
} as const;
export const ACCOUNT_TYPE_PLAYER_COUNTS = {
[ACCOUNT_TYPES.BASIC_20]: 20,
[ACCOUNT_TYPES.STANDARD_50]: 50,
[ACCOUNT_TYPES.PRO_100]: 100,
[ACCOUNT_TYPES.SCHOOL_500]: 500,
[ACCOUNT_TYPES.SCHOOL_1000]: 1000,
} as const;
export const ACCOUNT_TYPE_PRICES = {
[ACCOUNT_TYPES.BASIC_20]: 10000,
[ACCOUNT_TYPES.STANDARD_50]: 20000,
[ACCOUNT_TYPES.PRO_100]: 30000,
[ACCOUNT_TYPES.SCHOOL_500]: 40000,
[ACCOUNT_TYPES.SCHOOL_1000]: 50000,
} as const;
export const ACTIVATE_STATUSES = {
PENDING: 0,
TRIAL: 1,
ACTIVE: 2,
CANCELED: 100,
} as const;
export const ACTIVATE_STATUS_LABELS = {
[ACTIVATE_STATUSES.PENDING]: "미승인",
[ACTIVATE_STATUSES.TRIAL]: "체험",
[ACTIVATE_STATUSES.ACTIVE]: "활성화",
[ACTIVATE_STATUSES.CANCELED]: "등록 취소",
} as const;
export const REQUEST_STATUSES = {
REQUESTED: 1,
CLOSED: 2,
APPLIED: 3,
} as const;
export const REQUEST_STATUS_LABELS = {
[REQUEST_STATUSES.REQUESTED]: "요청",
[REQUEST_STATUSES.CLOSED]: "취소",
[REQUEST_STATUSES.APPLIED]: "적용 완료",
} as const;
export type AccountType = (typeof ACCOUNT_TYPES)[keyof typeof ACCOUNT_TYPES];
export type PaidAccountType =
| typeof ACCOUNT_TYPES.BASIC_20
| typeof ACCOUNT_TYPES.STANDARD_50
| typeof ACCOUNT_TYPES.PRO_100
| typeof ACCOUNT_TYPES.SCHOOL_500
| typeof ACCOUNT_TYPES.SCHOOL_1000;
export type ActivateStatus =
(typeof ACTIVATE_STATUSES)[keyof typeof ACTIVATE_STATUSES];
export type RequestStatus =
(typeof REQUEST_STATUSES)[keyof typeof REQUEST_STATUSES];
+25
View File
@@ -0,0 +1,25 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "@/lib/generated/prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
function createPrismaClient() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required to initialize Prisma Client.");
}
const adapter = new PrismaMariaDb(databaseUrl);
return new PrismaClient({ adapter });
}
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = db;
}
+282
View File
@@ -0,0 +1,282 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
calculateExtendedAvailableDate,
getAccountTypeLabel,
} from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const;
const extensionRequestSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(REQUEST_STATUSES.REQUESTED),
});
type PageSize = (typeof pageSizeOptions)[number];
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type ExtensionRequestSearchParams = z.infer<
typeof extensionRequestSearchSchema
>;
export type ExtensionRequestListItem = {
maestroExtensionID: number;
maestroID: number;
maestroName: string;
requestedAccountType: number;
currentAccountType: number;
activateStatus: number;
requestedDateTime: string;
availableActivateDateTime: string;
status: number;
};
export type ExtensionRequestListResult = {
items: ExtensionRequestListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
status?: number | "all";
};
export class ExtensionRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "ExtensionRequestActionError";
}
}
export async function getExtensionRequests(
rawSearchParams: RawSearchParams = {}
): Promise<ExtensionRequestListResult> {
const params = parseExtensionRequestSearchParams(rawSearchParams);
const where = buildExtensionRequestWhere(params);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, requests] = await Promise.all([
db.maestro_extension.count({ where }),
db.maestro_extension.findMany({
where,
orderBy: { MaestroExtensionID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroExtensionID: true,
MaestroID: true,
AccountType: true,
RequestedDateTime: true,
Status: true,
maestro: {
select: {
Name: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
},
},
},
}),
]);
return {
items: requests.map(toExtensionRequestListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
q: params.q,
status: params.status,
};
}
export async function approveExtensionRequest(
maestroExtensionID: number
): Promise<{ availableActivateDateTime: string }> {
return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID);
const availableActivateDateTime = calculateExtendedAvailableDate(
request.maestro.AvailableActivateDateTime
);
await tx.maestro.update({
where: { MaestroID: request.MaestroID },
data: {
ActivateStatus: 2,
AvailableActivateDateTime: availableActivateDateTime,
},
});
await tx.maestro_extension.updateMany({
where: {
MaestroID: request.MaestroID,
Status: REQUEST_STATUSES.REQUESTED,
},
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({
data: {
Type: "extension_maestro",
MaestroID: request.MaestroID,
LogDateTime: new Date(),
Remark: buildExtensionLogRemark(
request.maestro.Name,
request.AccountType
),
},
});
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
};
});
}
export async function cancelExtensionRequest(
maestroExtensionID: number
): Promise<void> {
await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroExtensionID);
await tx.maestro_extension.update({
where: { MaestroExtensionID: request.MaestroExtensionID },
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_log.create({
data: {
Type: "cancel_extension_maestro",
MaestroID: request.MaestroID,
LogDateTime: new Date(),
Remark: buildExtensionLogRemark(
request.maestro.Name,
request.AccountType
),
},
});
});
}
export function parseExtensionRequestSearchParams(
rawSearchParams: RawSearchParams
): ExtensionRequestSearchParams {
return extensionRequestSearchSchema.parse(
searchParamsToRecord(rawSearchParams)
);
}
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroExtensionID: number
) {
const request = await tx.maestro_extension.findUnique({
where: { MaestroExtensionID: maestroExtensionID },
include: { maestro: true },
});
if (!request) {
throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409);
}
return request;
}
function buildExtensionRequestWhere(
params: ExtensionRequestSearchParams
): Prisma.maestro_extensionWhereInput {
const conditions: Prisma.maestro_extensionWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestro_extensionWhereInput[] = [
{ maestro: { Name: { contains: params.q } } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift(
{ MaestroExtensionID: numericQuery },
{ MaestroID: numericQuery }
);
}
conditions.push({ OR: keywordConditions });
}
if (typeof params.status === "number" && Number.isInteger(params.status)) {
conditions.push({ Status: params.status });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toExtensionRequestListItem(request: {
MaestroExtensionID: number;
MaestroID: number;
AccountType: number;
RequestedDateTime: Date;
Status: number;
maestro: {
Name: string;
AccountType: number;
ActivateStatus: number;
AvailableActivateDateTime: Date;
};
}): ExtensionRequestListItem {
return {
maestroExtensionID: request.MaestroExtensionID,
maestroID: request.MaestroID,
maestroName: request.maestro.Name.trim(),
requestedAccountType: request.AccountType,
currentAccountType: request.maestro.AccountType,
activateStatus: request.maestro.ActivateStatus,
requestedDateTime: request.RequestedDateTime.toISOString(),
availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status,
};
}
function buildExtensionLogRemark(maestroName: string, accountType: number) {
return `${maestroName.trim()}(${getAccountTypeLabel(accountType)})`.slice(
0,
100
);
}
+370
View File
@@ -0,0 +1,370 @@
import { z } from "zod";
import { db } from "@/lib/db";
import type { Prisma } from "@/lib/generated/prisma/client";
const pageSizeOptions = [10, 20, 50, 100] as const;
const sortOptions = [
"id-desc",
"id-asc",
"joinedAt-desc",
"joinedAt-asc",
"availableAt-desc",
"availableAt-asc",
] as const;
const maestroListSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
activateStatus: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(undefined),
accountType: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(undefined),
sort: z.enum(sortOptions).catch("id-desc"),
});
type PageSize = (typeof pageSizeOptions)[number];
export type MaestroSort = (typeof sortOptions)[number];
export type MaestroListSearchParams = z.infer<typeof maestroListSearchSchema>;
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type MaestroListItem = {
maestroID: number;
name: string;
email: string;
accountType: number;
activateStatus: number;
availableActivateDateTime: string;
acceptClausesDateTime: string;
playerCount: number;
};
export type MaestroListResult = {
items: MaestroListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
activateStatus?: number | "all";
accountType?: number | "all";
sort: MaestroSort;
};
export type MaestroDetail = {
maestro: MaestroListItem & {
allowEditEnterCode: number;
maestroTestID: number | null;
};
counts: {
players: number;
extensionRequests: number;
upgradeRequests: number;
logs: number;
};
players: Array<{
playerID: number;
name: string;
enterCode: string;
accountType: number | null;
}>;
extensionRequests: Array<{
maestroExtensionID: number;
accountType: number;
requestedDateTime: string;
status: number;
}>;
upgradeRequests: Array<{
maestroUpgradeID: number;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
requestedDateTime: string;
status: number;
}>;
logs: Array<{
maestroLogID: number;
type: string;
logDateTime: string;
remark: string;
}>;
};
export async function getMaestros(
rawSearchParams: RawSearchParams = {}
): Promise<MaestroListResult> {
const params = parseMaestroListSearchParams(rawSearchParams);
const where = buildMaestroWhere(params);
const orderBy = buildMaestroOrderBy(params.sort);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, maestros] = await Promise.all([
db.maestro.count({ where }),
db.maestro.findMany({
where,
orderBy,
skip,
take: params.pageSize,
select: {
MaestroID: true,
Name: true,
Email: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
AcceptClausesDateTime: true,
PlayerCount: true,
},
}),
]);
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
return {
items: maestros.map(toMaestroListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages,
q: params.q,
activateStatus: params.activateStatus,
accountType: params.accountType,
sort: params.sort,
};
}
export async function getMaestroDetail(
maestroID: number
): Promise<MaestroDetail | null> {
const maestro = await db.maestro.findUnique({
where: { MaestroID: maestroID },
select: {
MaestroID: true,
Name: true,
Email: true,
AccountType: true,
ActivateStatus: true,
AvailableActivateDateTime: true,
AcceptClausesDateTime: true,
PlayerCount: true,
AllowEditEnterCode: true,
MaestroTestID: true,
},
});
if (!maestro) {
return null;
}
const [
playersCount,
extensionRequestsCount,
upgradeRequestsCount,
logsCount,
players,
extensionRequests,
upgradeRequests,
logs,
] = await Promise.all([
db.player.count({ where: { MaestroID: maestroID } }),
db.maestro_extension.count({ where: { MaestroID: maestroID } }),
db.maestro_upgrade.count({ where: { MaestroID: maestroID } }),
db.maestro_log.count({ where: { MaestroID: maestroID } }),
db.player.findMany({
where: { MaestroID: maestroID },
orderBy: { PlayerID: "desc" },
take: 20,
select: {
PlayerID: true,
Name: true,
EnterCode: true,
AccountType: true,
},
}),
db.maestro_extension.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroExtensionID: "desc" },
take: 20,
select: {
MaestroExtensionID: true,
AccountType: true,
RequestedDateTime: true,
Status: true,
},
}),
db.maestro_upgrade.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroUpgradeID: "desc" },
take: 20,
select: {
MaestroUpgradeID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
RequestedDateTime: true,
Status: true,
},
}),
db.maestro_log.findMany({
where: { MaestroID: maestroID },
orderBy: { MaestroLogID: "desc" },
take: 30,
select: {
MaestroLogID: true,
Type: true,
LogDateTime: true,
Remark: true,
},
}),
]);
return {
maestro: {
...toMaestroListItem(maestro),
allowEditEnterCode: maestro.AllowEditEnterCode,
maestroTestID: maestro.MaestroTestID,
},
counts: {
players: playersCount,
extensionRequests: extensionRequestsCount,
upgradeRequests: upgradeRequestsCount,
logs: logsCount,
},
players: players.map((player) => ({
playerID: player.PlayerID,
name: player.Name.trim(),
enterCode: player.EnterCode.trim(),
accountType: player.AccountType,
})),
extensionRequests: extensionRequests.map((request) => ({
maestroExtensionID: request.MaestroExtensionID,
accountType: request.AccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
})),
upgradeRequests: upgradeRequests.map((request) => ({
maestroUpgradeID: request.MaestroUpgradeID,
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
status: request.Status,
})),
logs: logs.map((log) => ({
maestroLogID: log.MaestroLogID,
type: log.Type.trim(),
logDateTime: log.LogDateTime.toISOString(),
remark: log.Remark.trim(),
})),
};
}
export function parseMaestroListSearchParams(
rawSearchParams: RawSearchParams
): MaestroListSearchParams {
return maestroListSearchSchema.parse(searchParamsToRecord(rawSearchParams));
}
function buildMaestroWhere(
params: MaestroListSearchParams
): Prisma.maestroWhereInput {
const conditions: Prisma.maestroWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestroWhereInput[] = [
{ Name: { contains: params.q } },
{ Email: { contains: params.q } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift({ MaestroID: numericQuery });
}
conditions.push({ OR: keywordConditions });
}
if (
typeof params.activateStatus === "number" &&
Number.isInteger(params.activateStatus)
) {
conditions.push({ ActivateStatus: params.activateStatus });
}
if (
typeof params.accountType === "number" &&
Number.isInteger(params.accountType)
) {
conditions.push({ AccountType: params.accountType });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function buildMaestroOrderBy(
sort: MaestroSort
): Prisma.maestroOrderByWithRelationInput {
switch (sort) {
case "id-asc":
return { MaestroID: "asc" };
case "joinedAt-desc":
return { AcceptClausesDateTime: "desc" };
case "joinedAt-asc":
return { AcceptClausesDateTime: "asc" };
case "availableAt-desc":
return { AvailableActivateDateTime: "desc" };
case "availableAt-asc":
return { AvailableActivateDateTime: "asc" };
case "id-desc":
default:
return { MaestroID: "desc" };
}
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toMaestroListItem(maestro: {
MaestroID: number;
Name: string;
Email: string;
AccountType: number;
ActivateStatus: number;
AvailableActivateDateTime: Date;
AcceptClausesDateTime: Date;
PlayerCount: number;
}): MaestroListItem {
return {
maestroID: maestro.MaestroID,
name: maestro.Name.trim(),
email: maestro.Email.trim(),
accountType: maestro.AccountType,
activateStatus: maestro.ActivateStatus,
availableActivateDateTime: maestro.AvailableActivateDateTime.toISOString(),
acceptClausesDateTime: maestro.AcceptClausesDateTime.toISOString(),
playerCount: maestro.PlayerCount,
};
}
+325
View File
@@ -0,0 +1,325 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client";
import {
calculateUpgradePrice,
getAccountTypeLabel,
getAccountTypePrice,
getTrialAccountTypeLabel,
} from "@/lib/utils";
const pageSizeOptions = [10, 20, 50, 100] as const;
const upgradeRequestSearchSchema = z.object({
page: z.coerce.number().int().min(1).catch(1),
pageSize: z.coerce
.number()
.int()
.refine((value) => pageSizeOptions.includes(value as PageSize))
.catch(10),
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.catch(REQUEST_STATUSES.REQUESTED),
});
type PageSize = (typeof pageSizeOptions)[number];
export type RawSearchParams =
| URLSearchParams
| Record<string, string | string[] | undefined>;
export type UpgradeRequestSearchParams = z.infer<
typeof upgradeRequestSearchSchema
>;
export type UpgradeRequestListItem = {
maestroUpgradeID: number;
maestroID: number;
maestroName: string;
registeredActivateStatus: number;
registeredAccountType: number;
requestedAccountType: number;
requestedDateTime: string;
availableActivateDateTime: string;
status: number;
additionalPrice: number;
};
export type UpgradeRequestListResult = {
items: UpgradeRequestListItem[];
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
q: string;
status?: number | "all";
};
export class UpgradeRequestActionError extends Error {
constructor(
message: string,
readonly statusCode: number
) {
super(message);
this.name = "UpgradeRequestActionError";
}
}
export async function getUpgradeRequests(
rawSearchParams: RawSearchParams = {}
): Promise<UpgradeRequestListResult> {
const params = parseUpgradeRequestSearchParams(rawSearchParams);
const where = buildUpgradeRequestWhere(params);
const skip = (params.page - 1) * params.pageSize;
const [totalCount, requests] = await Promise.all([
db.maestro_upgrade.count({ where }),
db.maestro_upgrade.findMany({
where,
orderBy: { MaestroUpgradeID: "desc" },
skip,
take: params.pageSize,
select: {
MaestroUpgradeID: true,
MaestroID: true,
RegisteredActivateStatus: true,
RegisteredAccountType: true,
RequestedAccountType: true,
RequestedDateTime: true,
Status: true,
maestro: {
select: {
Name: true,
AvailableActivateDateTime: true,
},
},
},
}),
]);
return {
items: requests.map(toUpgradeRequestListItem),
page: params.page,
pageSize: params.pageSize,
totalCount,
totalPages: Math.max(1, Math.ceil(totalCount / params.pageSize)),
q: params.q,
status: params.status,
};
}
export async function approveUpgradeRequest(
maestroUpgradeID: number
): Promise<{ availableActivateDateTime: string }> {
return db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID);
const availableActivateDateTime = calculateUpgradeAvailableDate();
await tx.maestro.update({
where: { MaestroID: request.MaestroID },
data: {
AccountType: request.RequestedAccountType,
ActivateStatus: ACTIVATE_STATUSES.ACTIVE,
AvailableActivateDateTime: availableActivateDateTime,
},
});
await tx.maestro_upgrade.updateMany({
where: {
MaestroID: request.MaestroID,
Status: REQUEST_STATUSES.REQUESTED,
},
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.APPLIED },
});
await tx.maestro_log.create({
data: {
Type: "upgrade_maestro",
MaestroID: request.MaestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus,
request.RegisteredAccountType,
request.RequestedAccountType
),
},
});
return {
availableActivateDateTime: availableActivateDateTime.toISOString(),
};
});
}
export async function rejectUpgradeRequest(
maestroUpgradeID: number
): Promise<void> {
await db.$transaction(async (tx) => {
const request = await getActionTarget(tx, maestroUpgradeID);
await tx.maestro_upgrade.update({
where: { MaestroUpgradeID: request.MaestroUpgradeID },
data: { Status: REQUEST_STATUSES.CLOSED },
});
await tx.maestro_log.create({
data: {
Type: "reject_upgrade_maestro",
MaestroID: request.MaestroID,
LogDateTime: new Date(),
Remark: buildUpgradeLogRemark(
request.RegisteredActivateStatus,
request.RegisteredAccountType,
request.RequestedAccountType
),
},
});
});
}
export function parseUpgradeRequestSearchParams(
rawSearchParams: RawSearchParams
): UpgradeRequestSearchParams {
return upgradeRequestSearchSchema.parse(
searchParamsToRecord(rawSearchParams)
);
}
async function getActionTarget(
tx: Prisma.TransactionClient,
maestroUpgradeID: number
) {
const request = await tx.maestro_upgrade.findUnique({
where: { MaestroUpgradeID: maestroUpgradeID },
});
if (!request) {
throw new UpgradeRequestActionError(
"업그레이드 신청을 찾을 수 없습니다.",
404
);
}
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
throw new UpgradeRequestActionError(
"이미 처리된 업그레이드 신청입니다.",
409
);
}
return request;
}
function buildUpgradeRequestWhere(
params: UpgradeRequestSearchParams
): Prisma.maestro_upgradeWhereInput {
const conditions: Prisma.maestro_upgradeWhereInput[] = [];
if (params.q) {
const keywordConditions: Prisma.maestro_upgradeWhereInput[] = [
{ maestro: { Name: { contains: params.q } } },
];
const numericQuery = Number(params.q);
if (Number.isInteger(numericQuery) && numericQuery > 0) {
keywordConditions.unshift(
{ MaestroUpgradeID: numericQuery },
{ MaestroID: numericQuery }
);
}
conditions.push({ OR: keywordConditions });
}
if (typeof params.status === "number" && Number.isInteger(params.status)) {
conditions.push({ Status: params.status });
}
return conditions.length > 0 ? { AND: conditions } : {};
}
function searchParamsToRecord(
rawSearchParams: RawSearchParams
): Record<string, string | undefined> {
if (rawSearchParams instanceof URLSearchParams) {
return Object.fromEntries(rawSearchParams.entries());
}
return Object.fromEntries(
Object.entries(rawSearchParams).map(([key, value]) => [
key,
Array.isArray(value) ? value[0] : value,
])
);
}
function toUpgradeRequestListItem(request: {
MaestroUpgradeID: number;
MaestroID: number;
RegisteredActivateStatus: number;
RegisteredAccountType: number;
RequestedAccountType: number;
RequestedDateTime: Date;
Status: number;
maestro: {
Name: string;
AvailableActivateDateTime: Date;
};
}): UpgradeRequestListItem {
return {
maestroUpgradeID: request.MaestroUpgradeID,
maestroID: request.MaestroID,
maestroName: request.maestro.Name.trim(),
registeredActivateStatus: request.RegisteredActivateStatus,
registeredAccountType: request.RegisteredAccountType,
requestedAccountType: request.RequestedAccountType,
requestedDateTime: request.RequestedDateTime.toISOString(),
availableActivateDateTime:
request.maestro.AvailableActivateDateTime.toISOString(),
status: request.Status,
additionalPrice: calculateAdditionalPrice(
request.RegisteredActivateStatus,
request.RegisteredAccountType,
request.RequestedAccountType
),
};
}
function calculateAdditionalPrice(
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number
) {
if (registeredActivateStatus === ACTIVATE_STATUSES.TRIAL) {
return getAccountTypePrice(requestedAccountType);
}
return calculateUpgradePrice(requestedAccountType, registeredAccountType);
}
function calculateUpgradeAvailableDate(now = new Date()) {
const availableDate = new Date(now);
availableDate.setFullYear(availableDate.getFullYear() + 1);
return availableDate;
}
function buildUpgradeLogRemark(
registeredActivateStatus: number,
registeredAccountType: number,
requestedAccountType: number
) {
const registeredLabel =
registeredActivateStatus === ACTIVATE_STATUSES.TRIAL
? getTrialAccountTypeLabel(registeredAccountType)
: getAccountTypeLabel(registeredAccountType);
return `${registeredLabel} -> ${getAccountTypeLabel(requestedAccountType)}`.slice(
0,
100
);
}
+119
View File
@@ -0,0 +1,119 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import {
ACCOUNT_TYPE_LABELS,
ACCOUNT_TYPE_PLAYER_COUNTS,
ACCOUNT_TYPE_PRICES,
ACTIVATE_STATUS_LABELS,
REQUEST_STATUS_LABELS,
TRIAL_ACCOUNT_TYPE_LABELS,
type AccountType,
type ActivateStatus,
type PaidAccountType,
type RequestStatus,
} from "@/lib/constants";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getAccountTypeLabel(accountType: number): string {
return (
ACCOUNT_TYPE_LABELS[accountType as AccountType] ?? `${accountType}? (N/A)`
);
}
export function getTrialAccountTypeLabel(accountType: number): string {
return (
TRIAL_ACCOUNT_TYPE_LABELS[accountType as PaidAccountType] ??
`${accountType}? (N/A)`
);
}
export function getAccountTypePlayerCount(accountType: number): number {
return ACCOUNT_TYPE_PLAYER_COUNTS[accountType as PaidAccountType] ?? 0;
}
export function getAccountTypePrice(accountType: number): number {
return ACCOUNT_TYPE_PRICES[accountType as PaidAccountType] ?? 0;
}
export function getActivateStatusLabel(activateStatus: number): string {
return (
ACTIVATE_STATUS_LABELS[activateStatus as ActivateStatus] ??
`${activateStatus}? (N/A)`
);
}
export function getRequestStatusLabel(status: number): string {
return REQUEST_STATUS_LABELS[status as RequestStatus] ?? `${status}? (N/A)`;
}
export function calculateUpgradePrice(
requestedAccountType: number,
registeredAccountType: number
): number {
return (
getAccountTypePrice(requestedAccountType) -
getAccountTypePrice(registeredAccountType)
);
}
export function formatDate(value: Date | string | null | undefined): string {
const date = toDate(value);
if (!date) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDateTime(
value: Date | string | null | undefined
): string {
const date = toDate(value);
if (!date) {
return "";
}
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${formatDate(date)} ${hours}:${minutes}:${seconds}`;
}
export function calculateExtendedAvailableDate(
availableDate: Date,
now = new Date()
): Date {
const baseDate =
availableDate.getTime() < now.getTime() ? now : availableDate;
const extendedDate = new Date(baseDate);
extendedDate.setFullYear(extendedDate.getFullYear() + 1);
extendedDate.setHours(0, 0, 0, 0);
return extendedDate;
}
function toDate(value: Date | string | null | undefined): Date | null {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+47
View File
@@ -0,0 +1,47 @@
{
"name": "chocoadmin",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.29.3",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:check": "tsx scripts/check-db.ts",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"mariadb": "^3.5.2",
"next": "16.2.7",
"next-auth": "5.0.0-beta.31",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.11.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"prettier": "^3.8.4",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5"
}
}
+7002
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
import { config } from "dotenv";
import { defineConfig } from "prisma/config";
config({ path: ".env.local" });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+254
View File
@@ -0,0 +1,254 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "mysql"
}
model active_app {
ActiveAppID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "active_app_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "active_app_ibfk_2")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
}
model admin {
AdminID Int @id @default(autoincrement()) @db.UnsignedInt
Name String @db.Char(50)
Password String @db.Char(50)
Email String @db.Char(50)
AccountType Int @db.UnsignedInt
ActivateStatus Int @db.UnsignedInt
}
model ads_client {
AdsClientID Int @id @default(autoincrement()) @db.UnsignedInt
ClientName String @db.Char(50)
typing_exam_ads typing_exam_ads[]
}
model app {
AppID Int @id @default(autoincrement()) @db.UnsignedInt
AppName String @db.Char(40)
KoreanName String @db.Char(40)
AppType Int @db.UnsignedInt
Status Int @db.UnsignedInt
HowToPlay String @db.Text
active_app active_app[]
app_highest_record app_highest_record[]
best_record best_record[]
}
model app_highest_record {
AppHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
HighestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "app_highest_record_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "app_highest_record_ibfk_2")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "app_highest_record_ibfk_3")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model banned_word {
BannedWordID Int @id @default(autoincrement()) @db.UnsignedInt
Word String @db.Char(50)
}
model best_record {
BestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
AppID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
BestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "best_record_ibfk_1")
app app @relation(fields: [AppID], references: [AppID], onUpdate: Restrict, map: "best_record_ibfk_2")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "best_record_ibfk_3")
@@index([AppID], map: "AppID")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model license_maestro_password {
LicenseMaestroPasswordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
Password Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_maestro_password_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model license_score {
LicenseScoreID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
SubjectName String @db.Char(50)
Score Int @db.UnsignedInt
ScoreDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_score_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_score_ibfk_2")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
model license_time {
LicenseTimeID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
StartTime Int @db.UnsignedInt
LeftTime Int
SavedDateTime DateTime @db.DateTime(0)
SubjectName String @db.Char(50)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "license_time_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "license_time_ibfk_2")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro {
MaestroID Int @id @default(autoincrement()) @db.UnsignedInt
Name String @db.Char(50)
Password String @db.Char(50)
Email String @db.Char(50)
AccountType Int @db.UnsignedInt
ActivateStatus Int @db.UnsignedInt
AvailableActivateDateTime DateTime @db.DateTime(0)
PlayerCount Int @db.UnsignedInt
AcceptClausesDateTime DateTime @db.DateTime(0)
AllowEditEnterCode Int @db.UnsignedInt
MaestroTestID Int? @db.UnsignedInt
active_app active_app[]
app_highest_record app_highest_record[]
best_record best_record[]
license_maestro_password license_maestro_password[]
license_score license_score[]
license_time license_time[]
maestro_extension maestro_extension[]
maestro_upgrade maestro_upgrade[]
player player[]
typing_exam_record typing_exam_record[]
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro_extension {
MaestroExtensionID Int @id @default(autoincrement()) @db.UnsignedInt
AccountType Int @db.UnsignedInt
RequestedDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
MaestroID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_extension_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model maestro_log {
MaestroLogID Int @id @default(autoincrement()) @db.UnsignedInt
Type String @db.Char(50)
MaestroID Int @db.UnsignedInt
LogDateTime DateTime @db.DateTime(0)
Remark String @db.Char(100)
}
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
model maestro_upgrade {
MaestroUpgradeID Int @id @default(autoincrement()) @db.UnsignedInt
RegisteredActivateStatus Int @default(1) @db.UnsignedInt
RegisteredAccountType Int
RequestedAccountType Int @db.UnsignedInt
RequestedDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
MaestroID Int @db.UnsignedInt
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "maestro_upgrade_ibfk_1")
@@index([MaestroID], map: "MaestroID")
}
model player {
PlayerID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
Name String @db.Char(50)
EnterCode String @db.Char(8)
AccountType Int? @default(0) @db.UnsignedInt
app_highest_record app_highest_record[]
best_record best_record[]
license_score license_score[]
license_time license_time[]
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "player_ibfk_1")
typing_exam_record typing_exam_record[]
@@index([MaestroID], map: "MaestroID")
}
model typing_exam_ads {
TypingExamAdsID Int @id @default(autoincrement()) @db.UnsignedInt
AdsClientID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
FromDateTime DateTime @db.DateTime(0)
ToDateTime DateTime @db.DateTime(0)
Status Int @db.UnsignedInt
Cost Float @db.Float
ads_client ads_client @relation(fields: [AdsClientID], references: [AdsClientID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_1")
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_ads_ibfk_2")
@@index([AdsClientID], map: "AdsClientID")
@@index([WritingID], map: "WritingID")
}
model typing_exam_highest_record {
TypingExamHighestRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
HighestRecord Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
@@index([WritingID], map: "WritingID")
}
model typing_exam_record {
TypingExamRecordID Int @id @default(autoincrement()) @db.UnsignedInt
MaestroID Int @db.UnsignedInt
PlayerID Int @db.UnsignedInt
WritingID Int @db.UnsignedInt
Record Float @db.Float
RecordDateTime DateTime @db.DateTime(0)
maestro maestro @relation(fields: [MaestroID], references: [MaestroID], onUpdate: Restrict, map: "typing_exam_record_ibfk_1")
player player @relation(fields: [PlayerID], references: [PlayerID], onUpdate: Restrict, map: "typing_exam_record_ibfk_2")
writing writing @relation(fields: [WritingID], references: [WritingID], onUpdate: Restrict, map: "typing_exam_record_ibfk_3")
@@index([MaestroID], map: "MaestroID")
@@index([PlayerID], map: "PlayerID")
@@index([WritingID], map: "WritingID")
}
model writing {
WritingID Int @id @default(autoincrement()) @db.UnsignedInt
ActivateStatus Int
Language String @db.Char(10)
Name String @db.Char(50)
Filename String @db.Char(50)
Writer String @db.Char(50)
WriterID Int? @db.UnsignedInt
UpdateDate DateTime @db.DateTime(0)
LetterCount Int @db.UnsignedInt
typing_exam_ads typing_exam_ads[]
typing_exam_record typing_exam_record[]
}
+30
View File
@@ -0,0 +1,30 @@
import { getToken } from "next-auth/jwt";
import { NextResponse, type NextRequest } from "next/server";
const PUBLIC_PATHS = ["/login"];
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
const isPublicPath = PUBLIC_PATHS.includes(pathname);
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
});
if (!token && !isPublicPath) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", `${pathname}${search}`);
return NextResponse.redirect(loginUrl);
}
if (token && isPublicPath) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+87
View File
@@ -0,0 +1,87 @@
import { config } from "dotenv";
config({ path: ".env.local" });
async function main() {
const { db } = await import("@/lib/db");
const { getAccountTypeLabel, getActivateStatusLabel, getRequestStatusLabel } =
await import("@/lib/utils");
const [maestroCount, extensionCount, upgradeCount] = await Promise.all([
db.maestro.count(),
db.maestro_extension.count(),
db.maestro_upgrade.count(),
]);
const [maestros, extensionRequests, upgradeRequests] = await Promise.all([
db.maestro.findMany({
orderBy: { MaestroID: "desc" },
select: {
MaestroID: true,
Name: true,
AccountType: true,
ActivateStatus: true,
PlayerCount: true,
},
take: 3,
}),
db.maestro_extension.findMany({
orderBy: { MaestroExtensionID: "desc" },
select: {
MaestroExtensionID: true,
MaestroID: true,
AccountType: true,
Status: true,
},
take: 3,
}),
db.maestro_upgrade.findMany({
orderBy: { MaestroUpgradeID: "desc" },
select: {
MaestroUpgradeID: true,
MaestroID: true,
RegisteredAccountType: true,
RequestedAccountType: true,
Status: true,
},
take: 3,
}),
]);
console.log("DB connection OK");
console.log({
counts: {
maestro: maestroCount,
maestro_extension: extensionCount,
maestro_upgrade: upgradeCount,
},
maestros: maestros.map((maestro) => ({
id: maestro.MaestroID,
name: maestro.Name,
accountType: getAccountTypeLabel(maestro.AccountType),
activateStatus: getActivateStatusLabel(maestro.ActivateStatus),
playerCount: maestro.PlayerCount,
})),
extensionRequests: extensionRequests.map((request) => ({
id: request.MaestroExtensionID,
maestroId: request.MaestroID,
accountType: getAccountTypeLabel(request.AccountType),
status: getRequestStatusLabel(request.Status),
})),
upgradeRequests: upgradeRequests.map((request) => ({
id: request.MaestroUpgradeID,
maestroId: request.MaestroID,
from: getAccountTypeLabel(request.RegisteredAccountType),
to: getAccountTypeLabel(request.RequestedAccountType),
status: getRequestStatusLabel(request.Status),
})),
});
await db.$disconnect();
}
main().catch((error) => {
console.error("DB connection check failed");
console.error(error);
process.exitCode = 1;
});
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+15
View File
@@ -0,0 +1,15 @@
export type {
admin,
maestro,
maestro_extension,
maestro_log,
maestro_upgrade,
player,
} from "@/lib/generated/prisma/client";
export type {
AccountType,
ActivateStatus,
PaidAccountType,
RequestStatus,
} from "@/lib/constants";