187 lines
5.2 KiB
TypeScript
187 lines
5.2 KiB
TypeScript
import { QuotaExtensionRequestStatus } from "@prisma/client";
|
|
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
|
|
import { prisma } from "./db";
|
|
import { getPlanLimits } from "./plans";
|
|
|
|
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
|
|
/** Max bonus videos an admin may grant per approval. */
|
|
export const MAX_ADMIN_BONUS_QUOTA = 50;
|
|
|
|
export const EXTENSION_KIND = {
|
|
VIDEO_QUOTA: "VIDEO_QUOTA",
|
|
API_RATE_LIMIT: "API_RATE_LIMIT",
|
|
} as const;
|
|
|
|
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
|
|
|
|
function getCalendarYearBounds(year = new Date().getFullYear()) {
|
|
return {
|
|
start: new Date(year, 0, 1),
|
|
end: new Date(year + 1, 0, 1),
|
|
};
|
|
}
|
|
|
|
export async function getQuotaExtensionUsage(
|
|
userId: string,
|
|
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
|
) {
|
|
const { start, end } = getCalendarYearBounds();
|
|
|
|
const requests = await prisma.quotaExtensionRequest.findMany({
|
|
where: {
|
|
userId,
|
|
kind,
|
|
requestedAt: { gte: start, lt: end },
|
|
},
|
|
orderBy: { requestedAt: "desc" },
|
|
});
|
|
|
|
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
|
|
|
|
return {
|
|
used,
|
|
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
|
|
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
|
|
kind,
|
|
requests: requests.map((r) => ({
|
|
id: r.id,
|
|
kind: r.kind,
|
|
status: r.status,
|
|
message: r.message,
|
|
requestedAt: r.requestedAt.toISOString(),
|
|
processedAt: r.processedAt?.toISOString() ?? null,
|
|
adminNote: r.adminNote,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function createQuotaExtensionRequest(
|
|
userId: string,
|
|
message = "",
|
|
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
|
|
) {
|
|
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
|
|
|
if (user.plan !== "PREMIUM") {
|
|
throw new Error("Only Pro subscribers can request quota resets or extensions.");
|
|
}
|
|
|
|
const usage = await getQuotaExtensionUsage(userId, kind);
|
|
if (usage.remaining <= 0) {
|
|
throw new Error(
|
|
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
|
|
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
|
|
} extension requests for this year.`,
|
|
);
|
|
}
|
|
|
|
const pending = await prisma.quotaExtensionRequest.findFirst({
|
|
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
|
|
});
|
|
if (pending) {
|
|
throw new Error("You already have a pending request. Please wait for it to be processed.");
|
|
}
|
|
|
|
const request = await prisma.quotaExtensionRequest.create({
|
|
data: {
|
|
userId,
|
|
kind,
|
|
message: message.trim().slice(0, 1000),
|
|
status: QuotaExtensionRequestStatus.PENDING,
|
|
},
|
|
});
|
|
|
|
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
|
|
|
|
return {
|
|
requestId: request.id,
|
|
...updatedUsage,
|
|
};
|
|
}
|
|
|
|
/** Call when approving a request in the database (admin / support). */
|
|
export async function approveQuotaExtensionRequest(
|
|
requestId: string,
|
|
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
|
|
) {
|
|
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
|
where: { id: requestId },
|
|
include: { user: true },
|
|
});
|
|
|
|
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
|
throw new Error("Request is not pending.");
|
|
}
|
|
|
|
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
|
|
const bonus = Math.min(
|
|
MAX_ADMIN_API_RATE_BONUS,
|
|
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
|
|
);
|
|
|
|
await prisma.$transaction([
|
|
prisma.quotaExtensionRequest.update({
|
|
where: { id: requestId },
|
|
data: {
|
|
status: QuotaExtensionRequestStatus.APPROVED,
|
|
processedAt: new Date(),
|
|
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
|
},
|
|
}),
|
|
prisma.user.update({
|
|
where: { id: request.userId },
|
|
data: {
|
|
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
|
|
},
|
|
}),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
const bonus = Math.min(
|
|
MAX_ADMIN_BONUS_QUOTA,
|
|
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
|
|
);
|
|
|
|
await prisma.$transaction([
|
|
prisma.quotaExtensionRequest.update({
|
|
where: { id: requestId },
|
|
data: {
|
|
status: QuotaExtensionRequestStatus.APPROVED,
|
|
processedAt: new Date(),
|
|
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
|
|
},
|
|
}),
|
|
prisma.user.update({
|
|
where: { id: request.userId },
|
|
data: {
|
|
videosUsed: 0,
|
|
bonusQuota: request.user.bonusQuota + bonus,
|
|
},
|
|
}),
|
|
]);
|
|
}
|
|
|
|
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
|
|
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
|
|
where: { id: requestId },
|
|
});
|
|
|
|
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
|
|
throw new Error("Request is not pending.");
|
|
}
|
|
|
|
await prisma.quotaExtensionRequest.update({
|
|
where: { id: requestId },
|
|
data: {
|
|
status: QuotaExtensionRequestStatus.REJECTED,
|
|
processedAt: new Date(),
|
|
adminNote: adminNote?.trim().slice(0, 500) ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
|
|
return getPlanLimits(plan).monthlyQuota + bonusQuota;
|
|
}
|