chore: quota limit check based on time range
This commit is contained in:
@@ -64,8 +64,8 @@ export interface UrlListProps {
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-10">
|
||||
<TableCell className="col-span-1">
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
||||
<TableCell className="col-span-1 sm:col-span-2">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 sm:col-span-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createUserEmail, getAllUserEmails } from "@/lib/dto/email";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { restrictByTimeRange, Team_Plan_Quota } from "@/lib/team";
|
||||
|
||||
// 查询所有 UserEmail 地址
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -41,6 +42,16 @@ export async function POST(req: NextRequest) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userEmail",
|
||||
userId: user.id,
|
||||
limit: Team_Plan_Quota[user.team].EM_EmailAddresses,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit.status !== 200)
|
||||
return NextResponse.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const { emailAddress } = await req.json();
|
||||
|
||||
if (!emailAddress) {
|
||||
@@ -71,6 +82,7 @@ export async function POST(req: NextRequest) {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+11
-12
@@ -4,7 +4,7 @@ import { getUserSendEmailCount, saveUserSendEmail } from "@/lib/dto/email";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { resend } from "@/lib/email";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { restrictByTimeRange, Team_Plan_Quota } from "@/lib/team";
|
||||
import { isValidEmail } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -12,16 +12,15 @@ export async function POST(req: NextRequest) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
// check quota
|
||||
const count = await getUserSendEmailCount(user.id, false);
|
||||
if (count >= Team_Plan_Quota[user.team].EM_SendEmails) {
|
||||
return Response.json(
|
||||
`Your email addresses have reached the ${user.team} limit.`,
|
||||
{
|
||||
status: 409,
|
||||
},
|
||||
);
|
||||
}
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userSendEmail",
|
||||
userId: user.id,
|
||||
limit: Team_Plan_Quota[user.team].EM_SendEmails,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit.status !== 200)
|
||||
return NextResponse.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const { from, to, subject, html } = await req.json();
|
||||
|
||||
@@ -33,7 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json("Invalid email address", { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { env } from "@/env.mjs";
|
||||
import { createUserShortUrl, getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { restrictByTimeRange, Team_Plan_Quota } from "@/lib/team";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -10,13 +10,15 @@ export async function POST(req: Request) {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
// check quota
|
||||
const user_urls_count = await getUserShortUrlCount(user.id);
|
||||
if (user_urls_count >= Team_Plan_Quota[user.team].SL_NewLinks) {
|
||||
return Response.json("Your short urls have reached the free limit.", {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: Team_Plan_Quota[user.team].SL_NewLinks,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit.status !== 200)
|
||||
return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const { data } = await req.json();
|
||||
|
||||
|
||||
+10
-10
@@ -7,7 +7,7 @@ import {
|
||||
getAllUserEmailsCount,
|
||||
} from "@/lib/dto/email";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { restrictByTimeRange, Team_Plan_Quota } from "@/lib/team";
|
||||
|
||||
import { siteConfig } from "../../../../config/site";
|
||||
|
||||
@@ -35,15 +35,15 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// check quota
|
||||
const user_address_count = await getAllUserEmailsCount(user.id);
|
||||
if (
|
||||
user_address_count >= Team_Plan_Quota[user.team || "free"].EM_EmailAddresses
|
||||
) {
|
||||
return Response.json("Your email addresses have reached the free limit.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userEmail",
|
||||
userId: user.id,
|
||||
limit: Team_Plan_Quota[user.team!].EM_EmailAddresses,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit.status !== 200)
|
||||
return NextResponse.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const { emailAddress } = await req.json();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { checkApiKey } from "@/lib/dto/api-key";
|
||||
import { createUserShortUrl, getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { restrictByTimeRange, Team_Plan_Quota } from "@/lib/team";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -21,13 +21,15 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// check quota
|
||||
const user_urls_count = await getUserShortUrlCount(user.id);
|
||||
if (user_urls_count >= Team_Plan_Quota[user.team || "free"].SL_NewLinks) {
|
||||
return Response.json("Your short urls have reached the free limit.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
// check limit
|
||||
const limit = await restrictByTimeRange({
|
||||
model: "userUrl",
|
||||
userId: user.id,
|
||||
limit: Team_Plan_Quota[user.team!].SL_NewLinks,
|
||||
rangeType: "month",
|
||||
});
|
||||
if (limit.status !== 200)
|
||||
return Response.json(limit.statusText, { status: limit.status });
|
||||
|
||||
const data = await req.json();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import useSWR from "swr";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { UserEmailList } from "@/lib/dto/email";
|
||||
import { reservedAddressSuffix } from "@/lib/enums";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { cn, fetcher, timeAgo } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import ApiReference from "@/app/emails/api-reference";
|
||||
@@ -349,6 +350,10 @@ export default function EmailSidebar({
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<CountUp count={sendEmails || 0} />
|
||||
{/* {" "}
|
||||
<span className="text-xs">
|
||||
/ {Team_Plan_Quota[user.team!].EM_SendEmails}
|
||||
</span> */}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,60 @@ import type { CSSProperties, ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Check, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Team_Plan_Quota } from "@/lib/team";
|
||||
import { cn, nFormatter } from "@/lib/utils";
|
||||
|
||||
import { Icons } from "../shared/icons";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const getBenefits = (plan) => [
|
||||
{
|
||||
text: `${nFormatter(plan.SL_TrackedClicks)} tracked clicks/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.mousePointerClick className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.SL_NewLinks)} new links/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.link className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${plan.SL_AnalyticsRetention}-day analytics retention`,
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.EM_EmailAddresses)} email addresses/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.mail className="size-4" />, // Updated icon to be more relevant
|
||||
},
|
||||
{
|
||||
text: `${nFormatter(plan.EM_SendEmails)} send emails/mo`,
|
||||
checked: true,
|
||||
icon: <Icons.send className="size-4" />, // Updated icon to be more relevant
|
||||
},
|
||||
{
|
||||
text: `${plan.SL_Domains === 1 ? "One" : plan.SL_Domains} domain${plan.SL_Domains > 1 ? "s" : ""}`,
|
||||
checked: true,
|
||||
icon: <Icons.globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Advanced analytics",
|
||||
checked: plan.SL_AdvancedAnalytics,
|
||||
icon: <Icons.lineChart className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: `${plan.APP_Support.charAt(0).toUpperCase() + plan.APP_Support.slice(1)} support`,
|
||||
checked: true,
|
||||
icon: <Icons.help className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "API Access",
|
||||
checked: plan.APP_ApiAccess,
|
||||
icon: <Icons.unplug className="size-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const PricingSection = () => {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-zinc-50 text-zinc-800 selection:bg-zinc-200 dark:bg-zinc-950 dark:text-zinc-200 dark:selection:bg-zinc-600">
|
||||
@@ -25,7 +74,7 @@ export const PricingSection = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<PriceCard
|
||||
tier="Free"
|
||||
price="$0/mo"
|
||||
@@ -35,67 +84,19 @@ export const PricingSection = () => {
|
||||
Get started free
|
||||
</Button>
|
||||
}
|
||||
benefits={[
|
||||
{
|
||||
text: "100K tracked clicks",
|
||||
checked: true,
|
||||
icon: <Icons.mousePointerClick className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "1k new links",
|
||||
checked: true,
|
||||
icon: <Icons.link className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "180-day analytics retention",
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "1k emails addresses",
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "One domain",
|
||||
checked: true,
|
||||
icon: <Icons.globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Advanced analytics",
|
||||
checked: true,
|
||||
icon: <Icons.lineChart className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Basic support",
|
||||
checked: true,
|
||||
icon: <Icons.help className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "API Access",
|
||||
checked: true,
|
||||
icon: <Icons.unplug className="size-4" />,
|
||||
},
|
||||
]}
|
||||
benefits={getBenefits(Team_Plan_Quota.free)}
|
||||
/>
|
||||
{/* <PriceCard
|
||||
<PriceCard
|
||||
tier="Pro"
|
||||
price="$79/mo"
|
||||
price="$5/mo"
|
||||
bestFor="Best for 5-50 users"
|
||||
CTA={
|
||||
<GhostButton className="w-full bg-zinc-800 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950 dark:hover:bg-zinc-200 dark:hover:text-zinc-900">
|
||||
<Button className="w-full bg-zinc-800 text-zinc-50 hover:bg-zinc-700 hover:text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950 dark:hover:bg-zinc-200 dark:hover:text-zinc-900">
|
||||
14-day free trial
|
||||
</GhostButton>
|
||||
</Button>
|
||||
}
|
||||
benefits={[
|
||||
{ text: "Five workspaces", checked: true },
|
||||
{ text: "Email support", checked: true },
|
||||
{ text: "7 day data retention", checked: true },
|
||||
{ text: "Custom roles", checked: true },
|
||||
{ text: "Priority support", checked: false },
|
||||
{ text: "SSO", checked: false },
|
||||
]}
|
||||
/> */}
|
||||
benefits={getBenefits(Team_Plan_Quota.premium)}
|
||||
/>
|
||||
<PriceCard
|
||||
tier="Enterprise"
|
||||
price="Contact us"
|
||||
@@ -105,48 +106,7 @@ export const PricingSection = () => {
|
||||
Contact us
|
||||
</Button>
|
||||
}
|
||||
benefits={[
|
||||
{
|
||||
text: "Unlimited tracked clicks/mo",
|
||||
checked: true,
|
||||
icon: <Icons.mousePointerClick className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Unlimited new links",
|
||||
checked: true,
|
||||
icon: <Icons.link className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "1-year analytics retention",
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "1k emails addresses",
|
||||
checked: true,
|
||||
icon: <Icons.calendar className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Three domain",
|
||||
checked: true,
|
||||
icon: <Icons.globe className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Advanced analytics",
|
||||
checked: true,
|
||||
icon: <Icons.lineChart className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "Basic support",
|
||||
checked: true,
|
||||
icon: <Icons.help className="size-4" />,
|
||||
},
|
||||
{
|
||||
text: "API Access",
|
||||
checked: true,
|
||||
icon: <Icons.unplug className="size-4" />,
|
||||
},
|
||||
]}
|
||||
benefits={getBenefits(Team_Plan_Quota.business)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+92
-13
@@ -1,26 +1,30 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { prisma } from "./db";
|
||||
|
||||
export const Team_Plan_Quota = {
|
||||
free: {
|
||||
SL_TrackedClicks: 100000,
|
||||
SL_NewLinks: 1000,
|
||||
SL_AnalyticsRetention: 180,
|
||||
SL_Domains: 1,
|
||||
SL_Domains: 2,
|
||||
SL_AdvancedAnalytics: true,
|
||||
RC_NewRecords: 0,
|
||||
EM_EmailAddresses: 1000,
|
||||
EM_Domains: 1,
|
||||
EM_SendEmails: 100,
|
||||
EM_Domains: 2,
|
||||
EM_SendEmails: 200,
|
||||
APP_Support: "basic",
|
||||
APP_ApiAccess: true,
|
||||
},
|
||||
premium: {
|
||||
SL_TrackedClicks: 10000000,
|
||||
SL_NewLinks: 10000,
|
||||
SL_TrackedClicks: 1000000,
|
||||
SL_NewLinks: 5000,
|
||||
SL_AnalyticsRetention: 360,
|
||||
SL_Domains: 3,
|
||||
SL_Domains: 2,
|
||||
SL_AdvancedAnalytics: true,
|
||||
RC_NewRecords: 3,
|
||||
EM_EmailAddresses: 10000,
|
||||
EM_Domains: 3,
|
||||
RC_NewRecords: 2,
|
||||
EM_EmailAddresses: 5000,
|
||||
EM_Domains: 2,
|
||||
EM_SendEmails: 1000,
|
||||
APP_Support: "live",
|
||||
APP_ApiAccess: true,
|
||||
@@ -29,13 +33,88 @@ export const Team_Plan_Quota = {
|
||||
SL_TrackedClicks: 10000000,
|
||||
SL_NewLinks: 10000,
|
||||
SL_AnalyticsRetention: 360,
|
||||
SL_Domains: 3,
|
||||
SL_Domains: 2,
|
||||
SL_AdvancedAnalytics: true,
|
||||
RC_NewRecords: 3,
|
||||
RC_NewRecords: 2,
|
||||
EM_EmailAddresses: 10000,
|
||||
EM_Domains: 3,
|
||||
EM_SendEmails: 1000,
|
||||
EM_Domains: 2,
|
||||
EM_SendEmails: 2000,
|
||||
APP_Support: "live",
|
||||
APP_ApiAccess: true,
|
||||
},
|
||||
};
|
||||
|
||||
type TimeRangeType = "day" | "month"; // 支持的限制周期
|
||||
|
||||
interface RestrictOptions {
|
||||
model: keyof PrismaClient; // Prisma 模型名称,如 'userEmail'
|
||||
userId: string; // 用户 ID
|
||||
limit: number; // 限制数量
|
||||
rangeType: TimeRangeType; // 限制周期:'day' 或 'month'
|
||||
referenceDate?: Date; // 可选,参考日期,默认为当前时间
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定时间范围检查记录数量是否超过限制
|
||||
* @throws 如果超过限制,抛出错误
|
||||
*/
|
||||
export async function restrictByTimeRange({
|
||||
model,
|
||||
userId,
|
||||
limit,
|
||||
rangeType,
|
||||
referenceDate,
|
||||
}: RestrictOptions) {
|
||||
const now = referenceDate || new Date();
|
||||
|
||||
let start: Date;
|
||||
let end: Date;
|
||||
|
||||
if (rangeType === "day") {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
end = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
} else if (rangeType === "month") {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
statusText: `Invalid range type: ${rangeType}`,
|
||||
};
|
||||
}
|
||||
|
||||
const modelInstance = prisma[model] as any;
|
||||
|
||||
if (!modelInstance) {
|
||||
return {
|
||||
status: 400,
|
||||
statusText: `Invalid model: ${model.toString()}`,
|
||||
};
|
||||
}
|
||||
|
||||
const count = await modelInstance.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (count >= limit) {
|
||||
return {
|
||||
status: 409,
|
||||
statusText: `You have exceeded the ${rangeType}ly ${model.toString()} creation limit (${limit}). Please try again later.`,
|
||||
};
|
||||
}
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user