chore: quota limit check based on time range

This commit is contained in:
oiov
2025-04-24 11:23:28 +08:00
parent f18883cdda
commit 3fd81e991d
10 changed files with 212 additions and 153 deletions
+2 -2
View File
@@ -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">
+12
View File
@@ -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
View File
@@ -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,
+10 -8
View File
@@ -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
View File
@@ -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();
+10 -8
View File
@@ -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();
+5
View File
@@ -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>
+59 -99
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long