체험 계정도 연장 승인되도록 수정
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.
|
- **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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user