체험 계정도 연장 승인되도록 수정

This commit is contained in:
2026-09-06 11:46:33 +09:00
parent 00d7659812
commit 56e4c0c29b
4 changed files with 20 additions and 17 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage`**never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic. - **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage`**never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`). - **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP. - **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim). - **Extension requests** (`POST /api/maestros/[id]/extension-requests`) accept `ActivateStatus = 2` (ACTIVE) or `1` (TRIAL). PENDING (0) and CANCELED (100) are rejected with 400. `AccountType` is not restricted — any type (paid tier or trial type) may request an extension. Approval (`PATCH /api/extension-requests/[id]`) mirrors this — accepts ACTIVE and TRIAL, promotes trial to ACTIVE on approval. PENDING/CANCELED throw 409 (transaction rolls back the atomic claim).
- **Upgrade requests**: TRIAL accounts (`ActivateStatus = 1`) may request the **same tier** (trial-to-paid conversion, `requestedAccountType >= AccountType`). ACTIVE accounts must use a strictly higher tier (`>`). At approval time, this direction is re-verified against the maestro's **current** `AccountType` — if it became invalid (e.g., admin manually raised the tier), the approval throws 409 and rolls back. - **Upgrade requests**: TRIAL accounts (`ActivateStatus = 1`) may request the **same tier** (trial-to-paid conversion, `requestedAccountType >= AccountType`). ACTIVE accounts must use a strictly higher tier (`>`). At approval time, this direction is re-verified against the maestro's **current** `AccountType` — if it became invalid (e.g., admin manually raised the tier), the approval throws 409 and rolls back.
- Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional. - Admin-initiated extension/upgrade registrations do **not** send an email (unlike self-registration flows which send payment-instruction emails). This is intentional.
@@ -85,7 +85,7 @@ Always record a log entry after a successful DB change. Use these `Type` values:
- Phase 10: Maestro info edit form (`PATCH /api/maestros/[id]`) + student list with server-side pagination (`GET /api/maestros/[id]/students`). - Phase 10: Maestro info edit form (`PATCH /api/maestros/[id]`) + student list with server-side pagination (`GET /api/maestros/[id]/students`).
- Phase 11: Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`). Upgrade email edge-case verification (trial→paid, paid→paid) still pending. - Phase 11: Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`). Upgrade email edge-case verification (trial→paid, paid→paid) still pending.
- Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page. - Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page.
- `POST /api/maestros/[id]/extension-requests` — inserts into `maestro_extension` using maestro's current `AccountType` read server-side; rejects trial account types. - `POST /api/maestros/[id]/extension-requests` — inserts into `maestro_extension` using maestro's current `AccountType` read server-side.
- `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`. - `POST /api/maestros/[id]/upgrade-requests` — inserts into `maestro_upgrade` inside a transaction; validates `requestedAccountType > maestro.AccountType`.
- `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success. - `ExtensionRequestsSection.tsx`, `UpgradeRequestsSection.tsx` — client components with inline action UI; call `router.refresh()` on success.
- `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes. - `PAID_ACCOUNT_TYPE_VALUES` consolidated in `lib/constants.ts` — shared by `lib/maestros.ts`, `lib/extension-requests.ts`, `lib/upgrade-requests.ts`, and both new API routes.
@@ -5,20 +5,17 @@ import { useRouter } from "next/navigation";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils"; import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
import type { MaestroDetail } from "@/lib/maestros"; import type { MaestroDetail } from "@/lib/maestros";
type ExtensionRequestsSectionProps = { type ExtensionRequestsSectionProps = {
maestroID: number; maestroID: number;
accountType: number;
totalCount: number; totalCount: number;
extensionRequests: MaestroDetail["extensionRequests"]; extensionRequests: MaestroDetail["extensionRequests"];
}; };
export function ExtensionRequestsSection({ export function ExtensionRequestsSection({
maestroID, maestroID,
accountType,
totalCount, totalCount,
extensionRequests, extensionRequests,
}: ExtensionRequestsSectionProps) { }: ExtensionRequestsSectionProps) {
@@ -27,7 +24,6 @@ export function ExtensionRequestsSection({
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isPaidAccount = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
const isDisabled = isCreating || isPending; const isDisabled = isCreating || isPending;
async function handleCreate() { async function handleCreate() {
@@ -73,7 +69,7 @@ export function ExtensionRequestsSection({
</div> </div>
<div className="flex flex-col items-start gap-1 sm:items-end"> <div className="flex flex-col items-start gap-1 sm:items-end">
<Button <Button
disabled={isDisabled || !isPaidAccount} disabled={isDisabled}
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
size="sm" size="sm"
type="button" type="button"
@@ -94,7 +94,6 @@ export default async function MaestroDetailPage({
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<ExtensionRequestsSection <ExtensionRequestsSection
accountType={maestro.accountType}
extensionRequests={detail.extensionRequests} extensionRequests={detail.extensionRequests}
maestroID={maestroID} maestroID={maestroID}
totalCount={detail.counts.extensionRequests} totalCount={detail.counts.extensionRequests}
+17 -9
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ApiError } from "@/lib/errors"; import { ApiError } from "@/lib/errors";
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants"; import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
import type { Prisma } from "@/lib/generated/prisma/client"; import type { Prisma } from "@/lib/generated/prisma/client";
import { import {
calculateExtendedAvailableDate, calculateExtendedAvailableDate,
@@ -145,8 +145,14 @@ export async function approveExtensionRequest(
}, },
}); });
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) { if (
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409); request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
) {
throw new ApiError(
"활성화 또는 체험 계정만 연장 승인이 가능합니다.",
409
);
} }
const availableActivateDateTime = calculateExtendedAvailableDate( const availableActivateDateTime = calculateExtendedAvailableDate(
@@ -200,12 +206,14 @@ export async function createExtensionRequest(
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404); throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
} }
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) { if (
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400); maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
} maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
) {
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) { throw new ApiError(
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400); "활성화 또는 체험 계정만 연장 신청이 가능합니다.",
400
);
} }
const request = await tx.maestro_extension.create({ const request = await tx.maestro_extension.create({