체험 계정도 연장 승인되도록 수정
This commit is contained in:
@@ -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.
|
||||
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
|
||||
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
|
||||
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
|
||||
- **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.
|
||||
- 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 11: Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`). Upgrade email edge-case verification (trial→paid, paid→paid) still pending.
|
||||
- Phase 12: Admin-initiated extension/upgrade request registration from maestro detail page.
|
||||
- `POST /api/maestros/[id]/extension-requests` — inserts into `maestro_extension` using maestro's current `AccountType` read server-side; rejects trial account types.
|
||||
- `POST /api/maestros/[id]/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`.
|
||||
- `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.
|
||||
|
||||
@@ -5,20 +5,17 @@ import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PAID_ACCOUNT_TYPE_VALUES } from "@/lib/constants";
|
||||
import { formatDateTime, getAccountTypeLabel, getRequestStatusLabel } from "@/lib/utils";
|
||||
import type { MaestroDetail } from "@/lib/maestros";
|
||||
|
||||
type ExtensionRequestsSectionProps = {
|
||||
maestroID: number;
|
||||
accountType: number;
|
||||
totalCount: number;
|
||||
extensionRequests: MaestroDetail["extensionRequests"];
|
||||
};
|
||||
|
||||
export function ExtensionRequestsSection({
|
||||
maestroID,
|
||||
accountType,
|
||||
totalCount,
|
||||
extensionRequests,
|
||||
}: ExtensionRequestsSectionProps) {
|
||||
@@ -27,7 +24,6 @@ export function ExtensionRequestsSection({
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const isPaidAccount = (PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(accountType);
|
||||
const isDisabled = isCreating || isPending;
|
||||
|
||||
async function handleCreate() {
|
||||
@@ -73,7 +69,7 @@ export function ExtensionRequestsSection({
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<Button
|
||||
disabled={isDisabled || !isPaidAccount}
|
||||
disabled={isDisabled}
|
||||
onClick={() => void handleCreate()}
|
||||
size="sm"
|
||||
type="button"
|
||||
|
||||
@@ -94,7 +94,6 @@ export default async function MaestroDetailPage({
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<ExtensionRequestsSection
|
||||
accountType={maestro.accountType}
|
||||
extensionRequests={detail.extensionRequests}
|
||||
maestroID={maestroID}
|
||||
totalCount={detail.counts.extensionRequests}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, PAID_ACCOUNT_TYPE_VALUES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
calculateExtendedAvailableDate,
|
||||
@@ -145,8 +145,14 @@ export async function approveExtensionRequest(
|
||||
},
|
||||
});
|
||||
|
||||
if (request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
||||
throw new ApiError("활성화된 계정만 연장 승인이 가능합니다.", 409);
|
||||
if (
|
||||
request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
|
||||
request!.maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
|
||||
) {
|
||||
throw new ApiError(
|
||||
"활성화 또는 체험 계정만 연장 승인이 가능합니다.",
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const availableActivateDateTime = calculateExtendedAvailableDate(
|
||||
@@ -200,12 +206,14 @@ export async function createExtensionRequest(
|
||||
throw new ApiError("마에스트로를 찾을 수 없습니다.", 404);
|
||||
}
|
||||
|
||||
if (!(PAID_ACCOUNT_TYPE_VALUES as readonly number[]).includes(maestro.AccountType)) {
|
||||
throw new ApiError("유료 요금제 계정만 연장 신청이 가능합니다.", 400);
|
||||
}
|
||||
|
||||
if (maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE) {
|
||||
throw new ApiError("활성화된 계정만 연장 신청이 가능합니다.", 400);
|
||||
if (
|
||||
maestro.ActivateStatus !== ACTIVATE_STATUSES.ACTIVE &&
|
||||
maestro.ActivateStatus !== ACTIVATE_STATUSES.TRIAL
|
||||
) {
|
||||
throw new ApiError(
|
||||
"활성화 또는 체험 계정만 연장 신청이 가능합니다.",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const request = await tx.maestro_extension.create({
|
||||
|
||||
Reference in New Issue
Block a user