feat: make billing better (#203)

This commit is contained in:
KM Koushik
2025-08-25 22:35:21 +10:00
committed by GitHub
parent 8ce5e4b2dd
commit 3f9094e95d
25 changed files with 956 additions and 360 deletions

View File

@@ -36,7 +36,6 @@
"@trpc/server": "^11.1.1", "@trpc/server": "^11.1.1",
"@unsend/email-editor": "workspace:*", "@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*", "@unsend/ui": "workspace:*",
"jsx-email": "^2.7.1",
"bullmq": "^5.51.1", "bullmq": "^5.51.1",
"chrono-node": "^2.8.0", "chrono-node": "^2.8.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -45,6 +44,7 @@
"hono": "^4.7.7", "hono": "^4.7.7",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"jsx-email": "^2.7.1",
"lucide-react": "^0.503.0", "lucide-react": "^0.503.0",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
@@ -67,7 +67,8 @@
"ua-parser-js": "^2.0.3", "ua-parser-js": "^2.0.3",
"unsend": "workspace:*", "unsend": "workspace:*",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.3" "zod": "^3.24.3",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@next/eslint-plugin-next": "^15.3.1", "@next/eslint-plugin-next": "^15.3.1",

View File

@@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "SendingDisabledReason" AS ENUM ('FREE_LIMIT_REACHED', 'BILLING_ISSUE', 'SPAM');
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "priceIds" TEXT[];
-- Populate priceIds array with existing priceId values
UPDATE "Subscription" SET "priceIds" = ARRAY["priceId"] WHERE "priceId" IS NOT NULL;

View File

@@ -138,6 +138,7 @@ model Subscription {
teamId Int teamId Int
status String status String
priceId String priceId String
priceIds String[]
currentPeriodEnd DateTime? currentPeriodEnd DateTime?
currentPeriodStart DateTime? currentPeriodStart DateTime?
cancelAtPeriodEnd DateTime? cancelAtPeriodEnd DateTime?

View File

@@ -26,6 +26,8 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@unsend/ui/src/form"; } from "@unsend/ui/src/form";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
const contactBookSchema = z.object({ const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, { name: z.string({ required_error: "Name is required" }).min(1, {
@@ -38,6 +40,11 @@ export default function AddContactBook() {
const createContactBookMutation = const createContactBookMutation =
api.contacts.createContactBook.useMutation(); api.contacts.createContactBook.useMutation();
const limitsQuery = api.limits.get.useQuery({
type: LimitReason.CONTACT_BOOK,
});
const { openModal } = useUpgradeModalStore((s) => s.action);
const utils = api.useUtils(); const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({ const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
@@ -48,6 +55,11 @@ export default function AddContactBook() {
}); });
function handleSave(values: z.infer<typeof contactBookSchema>) { function handleSave(values: z.infer<typeof contactBookSchema>) {
if (limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
createContactBookMutation.mutate( createContactBookMutation.mutate(
{ {
name: values.name, name: values.name,
@@ -59,14 +71,23 @@ export default function AddContactBook() {
setOpen(false); setOpen(false);
toast.success("Contact book created successfully"); toast.success("Contact book created successfully");
}, },
} },
); );
} }
function onOpenChange(_open: boolean) {
if (_open && limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
setOpen(_open);
}
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} onOpenChange={(_open) => (_open !== open ? onOpenChange(_open) : null)}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
@@ -108,7 +129,9 @@ export default function AddContactBook() {
<Button <Button
className=" w-[100px]" className=" w-[100px]"
type="submit" type="submit"
disabled={createContactBookMutation.isPending} disabled={
createContactBookMutation.isPending || limitsQuery.isLoading
}
> >
{createContactBookMutation.isPending {createContactBookMutation.isPending
? "Creating..." ? "Creating..."

View File

@@ -4,6 +4,7 @@ import { AppSidebar } from "~/components/AppSideBar";
import { SidebarInset, SidebarTrigger } from "@unsend/ui/src/sidebar"; import { SidebarInset, SidebarTrigger } from "@unsend/ui/src/sidebar";
import { SidebarProvider } from "@unsend/ui/src/sidebar"; import { SidebarProvider } from "@unsend/ui/src/sidebar";
import { useIsMobile } from "@unsend/ui/src/hooks/use-mobile"; import { useIsMobile } from "@unsend/ui/src/hooks/use-mobile";
import { UpgradeModal } from "~/components/payments/UpgradeModal";
export function DashboardLayout({ children }: { children: React.ReactNode }) { export function DashboardLayout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -21,6 +22,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</main> </main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
<UpgradeModal />
</div> </div>
); );
} }

View File

@@ -35,6 +35,8 @@ import {
SelectValue, SelectValue,
} from "@unsend/ui/src/select"; } from "@unsend/ui/src/select";
import { toast } from "@unsend/ui/src/toaster"; import { toast } from "@unsend/ui/src/toaster";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
const domainSchema = z.object({ const domainSchema = z.object({
region: z.string({ required_error: "Region is required" }).min(1, { region: z.string({ required_error: "Region is required" }).min(1, {
@@ -49,6 +51,9 @@ export default function AddDomain() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const regionQuery = api.domain.getAvailableRegions.useQuery(); const regionQuery = api.domain.getAvailableRegions.useQuery();
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.DOMAIN });
const { openModal } = useUpgradeModalStore((s) => s.action);
const addDomainMutation = api.domain.createDomain.useMutation(); const addDomainMutation = api.domain.createDomain.useMutation();
@@ -73,6 +78,11 @@ export default function AddDomain() {
return; return;
} }
if (limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
addDomainMutation.mutate( addDomainMutation.mutate(
{ {
name: values.domain, name: values.domain,
@@ -91,10 +101,19 @@ export default function AddDomain() {
); );
} }
function onOpenChange(_open: boolean) {
if (_open && limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
setOpen(_open);
}
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} onOpenChange={(_open) => (_open !== open ? onOpenChange(_open) : null)}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
@@ -171,7 +190,9 @@ export default function AddDomain() {
<Button <Button
className=" w-[100px]" className=" w-[100px]"
type="submit" type="submit"
disabled={addDomainMutation.isPending} disabled={
addDomainMutation.isPending || limitsQuery.isLoading
}
> >
{addDomainMutation.isPending ? "Adding..." : "Add"} {addDomainMutation.isPending ? "Adding..." : "Add"}
</Button> </Button>

View File

@@ -34,6 +34,8 @@ import {
} from "@unsend/ui/src/form"; } from "@unsend/ui/src/form";
import { useTeam } from "~/providers/team-context"; import { useTeam } from "~/providers/team-context";
import { isCloud, isSelfHosted } from "~/utils/common"; import { isCloud, isSelfHosted } from "~/utils/common";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
const inviteTeamMemberSchema = z.object({ const inviteTeamMemberSchema = z.object({
email: z email: z
@@ -50,6 +52,11 @@ export default function InviteTeamMember() {
const { currentIsAdmin } = useTeam(); const { currentIsAdmin } = useTeam();
const { data: domains } = api.domain.domains.useQuery(); const { data: domains } = api.domain.domains.useQuery();
const limitsQuery = api.limits.get.useQuery({
type: LimitReason.TEAM_MEMBER,
});
const { openModal } = useUpgradeModalStore((s) => s.action);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -65,6 +72,11 @@ export default function InviteTeamMember() {
const createInvite = api.team.createTeamInvite.useMutation(); const createInvite = api.team.createTeamInvite.useMutation();
function onSubmit(values: FormData) { function onSubmit(values: FormData) {
if (limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
createInvite.mutate( createInvite.mutate(
{ {
email: values.email, email: values.email,
@@ -82,11 +94,16 @@ export default function InviteTeamMember() {
console.error(error); console.error(error);
toast.error(error.message || "Failed to send invitation"); toast.error(error.message || "Failed to send invitation");
}, },
} },
); );
} }
async function onCopyLink() { async function onCopyLink() {
if (limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
createInvite.mutate( createInvite.mutate(
{ {
email: form.getValues("email"), email: form.getValues("email"),
@@ -97,7 +114,7 @@ export default function InviteTeamMember() {
onSuccess: (invite) => { onSuccess: (invite) => {
void utils.team.getTeamInvites.invalidate(); void utils.team.getTeamInvites.invalidate();
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${location.origin}/join-team?inviteId=${invite.id}` `${location.origin}/join-team?inviteId=${invite.id}`,
); );
form.reset(); form.reset();
setOpen(false); setOpen(false);
@@ -107,16 +124,28 @@ export default function InviteTeamMember() {
console.error(error); console.error(error);
toast.error(error.message || "Failed to copy invitation link"); toast.error(error.message || "Failed to copy invitation link");
}, },
} },
); );
} }
function onOpenChange(_open: boolean) {
if (_open && limitsQuery.data?.isLimitReached) {
openModal(limitsQuery.data.reason);
return;
}
setOpen(_open);
}
if (!currentIsAdmin) { if (!currentIsAdmin) {
return null; return null;
} }
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(_open) => (_open !== open ? onOpenChange(_open) : null)}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
@@ -201,7 +230,7 @@ export default function InviteTeamMember() {
</Button> </Button>
{isSelfHosted() ? ( {isSelfHosted() ? (
<Button <Button
disabled={createInvite.isPending} disabled={createInvite.isPending || limitsQuery.isLoading}
isLoading={createInvite.isPending} isLoading={createInvite.isPending}
className="w-[150px]" className="w-[150px]"
onClick={form.handleSubmit(onCopyLink)} onClick={form.handleSubmit(onCopyLink)}
@@ -212,7 +241,7 @@ export default function InviteTeamMember() {
{isCloud() || domains?.length ? ( {isCloud() || domains?.length ? (
<Button <Button
type="submit" type="submit"
disabled={createInvite.isPending} disabled={createInvite.isPending || limitsQuery.isLoading}
isLoading={createInvite.isPending} isLoading={createInvite.isPending}
className="w-[150px]" className="w-[150px]"
> >

View File

@@ -6,6 +6,7 @@ import Spinner from "@unsend/ui/src/spinner";
import { useTeam } from "~/providers/team-context"; import { useTeam } from "~/providers/team-context";
import { Badge } from "@unsend/ui/src/badge"; import { Badge } from "@unsend/ui/src/badge";
import { format } from "date-fns"; import { format } from "date-fns";
export const PlanDetails = () => { export const PlanDetails = () => {
const subscriptionQuery = api.billing.getSubscriptionDetails.useQuery(); const subscriptionQuery = api.billing.getSubscriptionDetails.useQuery();
const { currentTeam } = useTeam(); const { currentTeam } = useTeam();

View File

@@ -0,0 +1,67 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@unsend/ui/src/dialog";
import { CheckCircle2 } from "lucide-react";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { PLAN_PERKS } from "~/lib/constants/payments";
import { LimitReason } from "~/lib/constants/plans";
import { UpgradeButton } from "./UpgradeButton";
export const UpgradeModal = () => {
const {
isOpen,
reason,
action: { closeModal },
} = useUpgradeModalStore();
const basicPlanPerks = PLAN_PERKS.BASIC || [];
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Upgrade to Basic Plan</DialogTitle>
<DialogDescription>
{(() => {
const messages: Record<LimitReason, string> = {
[LimitReason.DOMAIN]:
"You've reached the domain limit for your current plan.",
[LimitReason.CONTACT_BOOK]:
"You've reached the contact book limit for your current plan.",
[LimitReason.TEAM_MEMBER]:
"You've reached the team member limit for your current plan.",
[LimitReason.EMAIL]:
"You've reached the email sending limit for your current plan.",
};
return reason
? `${messages[reason] ?? ""} Upgrade to unlock this feature and more.`
: "Unlock more features with our Basic plan.";
})()}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="font-medium mb-3">What you'll get:</h4>
<ul className="space-y-2">
{basicPlanPerks.map((perk, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-sm">{perk}</span>
</li>
))}
</ul>
</div>
<UpgradeButton />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -53,6 +53,8 @@ export const env = createEnv({
S3_COMPATIBLE_PUBLIC_URL: z.string().optional(), S3_COMPATIBLE_PUBLIC_URL: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(), STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_BASIC_PRICE_ID: z.string().optional(), STRIPE_BASIC_PRICE_ID: z.string().optional(),
STRIPE_BASIC_USAGE_PRICE_ID: z.string().optional(),
STRIPE_LEGACY_BASIC_PRICE_ID: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(),
SMTP_HOST: z.string().default("smtp.unsend.dev"), SMTP_HOST: z.string().default("smtp.unsend.dev"),
SMTP_USER: z.string().default("unsend"), SMTP_USER: z.string().default("unsend"),
@@ -102,6 +104,8 @@ export const env = createEnv({
S3_COMPATIBLE_PUBLIC_URL: process.env.S3_COMPATIBLE_PUBLIC_URL, S3_COMPATIBLE_PUBLIC_URL: process.env.S3_COMPATIBLE_PUBLIC_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_BASIC_PRICE_ID: process.env.STRIPE_BASIC_PRICE_ID, STRIPE_BASIC_PRICE_ID: process.env.STRIPE_BASIC_PRICE_ID,
STRIPE_BASIC_USAGE_PRICE_ID: process.env.STRIPE_BASIC_USAGE_PRICE_ID,
STRIPE_LEGACY_BASIC_PRICE_ID: process.env.STRIPE_LEGACY_BASIC_PRICE_ID,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
SMTP_HOST: process.env.SMTP_HOST, SMTP_HOST: process.env.SMTP_HOST,
SMTP_USER: process.env.SMTP_USER, SMTP_USER: process.env.SMTP_USER,

View File

@@ -0,0 +1,34 @@
import { Plan } from "@prisma/client";
export enum LimitReason {
DOMAIN = "DOMAIN",
CONTACT_BOOK = "CONTACT_BOOK",
TEAM_MEMBER = "TEAM_MEMBER",
EMAIL = "EMAIL",
}
export const PLAN_LIMITS: Record<
Plan,
{
emailsPerMonth: number;
emailsPerDay: number;
domains: number;
contactBooks: number;
teamMembers: number;
}
> = {
FREE: {
emailsPerMonth: 3000,
emailsPerDay: 100,
domains: 1,
contactBooks: 1,
teamMembers: 1,
},
BASIC: {
emailsPerMonth: -1, // unlimited
emailsPerDay: -1, // unlimited
domains: -1,
contactBooks: -1,
teamMembers: -1,
},
};

View File

@@ -1,4 +1,4 @@
import { EmailUsageType, Plan } from "@prisma/client"; import { EmailUsageType, Plan, Subscription } from "@prisma/client";
export const USAGE_UNIT_PRICE: Record<EmailUsageType, number> = { export const USAGE_UNIT_PRICE: Record<EmailUsageType, number> = {
[EmailUsageType.MARKETING]: 0.001, [EmailUsageType.MARKETING]: 0.001,

View File

@@ -11,6 +11,7 @@ import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation"; import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard"; import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression"; import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
invitation: invitationRouter, invitation: invitationRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
suppression: suppressionRouter, suppression: suppressionRouter,
limits: limitsRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -2,6 +2,7 @@ import { DailyEmailUsage, EmailUsageType, Subscription } from "@prisma/client";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { format, sub } from "date-fns"; import { format, sub } from "date-fns";
import { z } from "zod"; import { z } from "zod";
import { getThisMonthUsage } from "~/server/service/usage-service";
import { import {
apiKeyProcedure, apiKeyProcedure,
@@ -25,48 +26,7 @@ export const billingRouter = createTRPCRouter({
}), }),
getThisMonthUsage: teamProcedure.query(async ({ ctx }) => { getThisMonthUsage: teamProcedure.query(async ({ ctx }) => {
const isPaidPlan = ctx.team.plan !== "FREE"; return await getThisMonthUsage(ctx.team.id);
let subscription: Subscription | null = null;
if (isPaidPlan) {
subscription = await db.subscription.findFirst({
where: { teamId: ctx.team.id },
orderBy: { status: "asc" },
});
}
const isoStartDate = subscription?.currentPeriodStart
? format(subscription.currentPeriodStart, "yyyy-MM-dd")
: format(new Date(), "yyyy-MM-01"); // First day of current month
const today = format(new Date(), "yyyy-MM-dd");
const [monthUsage, dayUsage] = await Promise.all([
// Get month usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${ctx.team.id}
AND "date" >= ${isoStartDate}
GROUP BY "type"
`,
// Get today's usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${ctx.team.id}
AND "date" = ${today}
GROUP BY "type"
`,
]);
return {
month: monthUsage,
day: dayUsage,
};
}), }),
getSubscriptionDetails: teamProcedure.query(async ({ ctx }) => { getSubscriptionDetails: teamProcedure.query(async ({ ctx }) => {

View File

@@ -7,24 +7,13 @@ import {
teamProcedure, teamProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import * as contactService from "~/server/service/contact-service"; import * as contactService from "~/server/service/contact-service";
import * as contactBookService from "~/server/service/contact-book-service";
export const contactsRouter = createTRPCRouter({ export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure getContactBooks: teamProcedure
.input(z.object({ search: z.string().optional() })) .input(z.object({ search: z.string().optional() }))
.query(async ({ ctx: { db, team }, input }) => { .query(async ({ ctx: { team }, input }) => {
return db.contactBook.findMany({ return contactBookService.getContactBooks(team.id, input.search);
where: {
teamId: team.id,
...(input.search
? { name: { contains: input.search, mode: "insensitive" } }
: {}),
},
include: {
_count: {
select: { contacts: true },
},
},
});
}), }),
createContactBook: teamProcedure createContactBook: teamProcedure
@@ -33,40 +22,15 @@ export const contactsRouter = createTRPCRouter({
name: z.string(), name: z.string(),
}) })
) )
.mutation(async ({ ctx: { db, team }, input }) => { .mutation(async ({ ctx: { team }, input }) => {
const { name } = input; const { name } = input;
const contactBook = await db.contactBook.create({ return contactBookService.createContactBook(team.id, name);
data: {
name,
teamId: team.id,
properties: {},
},
});
return contactBook;
}), }),
getContactBookDetails: contactBookProcedure.query( getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook, db } }) => { async ({ ctx: { contactBook } }) => {
const [totalContacts, unsubscribedContacts, campaigns] = const { totalContacts, unsubscribedContacts, campaigns } =
await Promise.all([ await contactBookService.getContactBookDetails(contactBook.id);
db.contact.count({
where: { contactBookId: contactBook.id },
}),
db.contact.count({
where: { contactBookId: contactBook.id, subscribed: false },
}),
db.campaign.findMany({
where: {
contactBookId: contactBook.id,
status: CampaignStatus.SENT,
},
orderBy: {
createdAt: "desc",
},
take: 2,
}),
]);
return { return {
...contactBook, ...contactBook,
@@ -86,18 +50,14 @@ export const contactsRouter = createTRPCRouter({
emoji: z.string().optional(), emoji: z.string().optional(),
}) })
) )
.mutation(async ({ ctx: { db }, input }) => { .mutation(async ({ ctx: { contactBook }, input }) => {
const { contactBookId, ...data } = input; return contactBookService.updateContactBook(contactBook.id, input);
return db.contactBook.update({
where: { id: contactBookId },
data,
});
}), }),
deleteContactBook: contactBookProcedure deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() })) .input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { db }, input }) => { .mutation(async ({ ctx: { contactBook }, input }) => {
return db.contactBook.delete({ where: { id: input.contactBookId } }); return contactBookService.deleteContactBook(contactBook.id);
}), }),
contacts: contactBookProcedure contacts: contactBookProcedure

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { LimitService } from "~/server/service/limit-service";
import { LimitReason } from "~/lib/constants/plans";
export const limitsRouter = createTRPCRouter({
get: teamProcedure
.input(
z.object({
type: z.nativeEnum(LimitReason),
}),
)
.query(async ({ ctx, input }) => {
switch (input.type) {
case LimitReason.CONTACT_BOOK:
return LimitService.checkContactBookLimit(ctx.team.id);
case LimitReason.DOMAIN:
return LimitService.checkDomainLimit(ctx.team.id);
case LimitReason.TEAM_MEMBER:
return LimitService.checkTeamMemberLimit(ctx.team.id);
case LimitReason.EMAIL:
return LimitService.checkEmailLimit(ctx.team.id);
default:
// exhaustive guard
throw new Error("Unsupported limit type");
}
}),
});

View File

@@ -1,6 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env";
import { import {
createTRPCRouter, createTRPCRouter,
@@ -8,94 +6,25 @@ import {
teamProcedure, teamProcedure,
teamAdminProcedure, teamAdminProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { sendTeamInviteEmail } from "~/server/mailer"; import { TeamService } from "~/server/service/team-service";
import send from "~/server/public-api/api/emails/send-email";
import { logger } from "~/server/logger/log";
export const teamRouter = createTRPCRouter({ export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure createTeam: protectedProcedure
.input(z.object({ name: z.string() })) .input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const teams = await ctx.db.team.findMany({ return TeamService.createTeam(ctx.session.user.id, input.name);
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
});
if (teams.length > 0) {
logger.info({ userId: ctx.session.user.id }, "User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await ctx.db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return ctx.db.team.create({
data: {
name: input.name,
teamUsers: {
create: {
userId: ctx.session.user.id,
role: "ADMIN",
},
},
},
});
}), }),
getTeams: protectedProcedure.query(async ({ ctx }) => { getTeams: protectedProcedure.query(async ({ ctx }) => {
const teams = await ctx.db.team.findMany({ return TeamService.getUserTeams(ctx.session.user.id);
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
include: {
teamUsers: {
where: {
userId: ctx.session.user.id,
},
},
},
});
return teams;
}), }),
getTeamUsers: teamProcedure.query(async ({ ctx }) => { getTeamUsers: teamProcedure.query(async ({ ctx }) => {
const teamUsers = await ctx.db.teamUser.findMany({ return TeamService.getTeamUsers(ctx.team.id);
where: {
teamId: ctx.team.id,
},
include: {
user: true,
},
});
return teamUsers;
}), }),
getTeamInvites: teamProcedure.query(async ({ ctx }) => { getTeamInvites: teamProcedure.query(async ({ ctx }) => {
const teamInvites = await ctx.db.teamInvite.findMany({ return TeamService.getTeamInvites(ctx.team.id);
where: {
teamId: ctx.team.id,
},
});
return teamInvites;
}), }),
createTeamInvite: teamAdminProcedure createTeamInvite: teamAdminProcedure
@@ -107,44 +36,13 @@ export const teamRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (!input.email) { return TeamService.createTeamInvite(
throw new TRPCError({ ctx.team.id,
code: "BAD_REQUEST", input.email,
message: "Email is required", input.role,
}); ctx.team.name,
} input.sendEmail
);
const user = await ctx.db.user.findUnique({
where: {
email: input.email,
},
include: {
teamUsers: true,
},
});
if (user && user.teamUsers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User already part of a team",
});
}
const teamInvite = await ctx.db.teamInvite.create({
data: {
teamId: ctx.team.id,
email: input.email,
role: input.role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
if (input.sendEmail) {
await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name);
}
return teamInvite;
}), }),
updateTeamUserRole: teamAdminProcedure updateTeamUserRole: teamAdminProcedure
@@ -155,150 +53,33 @@ export const teamRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({ return TeamService.updateTeamUserRole(
where: { ctx.team.id,
teamId: ctx.team.id, input.userId,
userId: Number(input.userId), input.role
}, );
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.update({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
data: {
role: input.role,
},
});
}), }),
deleteTeamUser: teamProcedure deleteTeamUser: teamProcedure
.input(z.object({ userId: z.string() })) .input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({ return TeamService.deleteTeamUser(
where: { ctx.team.id,
teamId: ctx.team.id, input.userId,
userId: Number(input.userId), ctx.teamUser.role,
}, ctx.session.user.id
}); );
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (
ctx.teamUser.role !== "ADMIN" &&
ctx.session.user.id !== Number(input.userId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this team member",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.delete({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
});
}), }),
resendTeamInvite: teamAdminProcedure resendTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() })) .input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({ return TeamService.resendTeamInvite(input.inviteId, ctx.team.name);
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
// TODO: Implement email sending logic
await sendTeamInviteEmail(invite.email, teamUrl, ctx.team.name);
return { success: true };
}), }),
deleteTeamInvite: teamAdminProcedure deleteTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() })) .input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findFirst({ return TeamService.deleteTeamInvite(ctx.team.id, input.inviteId);
where: {
teamId: ctx.team.id,
id: {
equals: input.inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return ctx.db.teamInvite.delete({
where: {
teamId_email: {
teamId: ctx.team.id,
email: invite.email,
},
},
});
}), }),
}); });

View File

@@ -47,7 +47,11 @@ export async function createCheckoutSessionForTeam(teamId: number) {
customerId = customer.id; customerId = customer.id;
} }
if (!env.STRIPE_BASIC_PRICE_ID || !customerId) { if (
!env.STRIPE_BASIC_PRICE_ID ||
!env.STRIPE_BASIC_USAGE_PRICE_ID ||
!customerId
) {
throw new Error("Stripe prices are not set"); throw new Error("Stripe prices are not set");
} }
@@ -57,6 +61,10 @@ export async function createCheckoutSessionForTeam(teamId: number) {
line_items: [ line_items: [
{ {
price: env.STRIPE_BASIC_PRICE_ID, price: env.STRIPE_BASIC_PRICE_ID,
quantity: 1,
},
{
price: env.STRIPE_BASIC_USAGE_PRICE_ID,
}, },
], ],
success_url: `${env.NEXTAUTH_URL}/payments?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${env.NEXTAUTH_URL}/payments?success=true&session_id={CHECKOUT_SESSION_ID}`,
@@ -70,8 +78,13 @@ export async function createCheckoutSessionForTeam(teamId: number) {
return session; return session;
} }
function getPlanFromPriceId(priceId: string) { function getPlanFromPriceIds(priceIds: string[]) {
if (priceId === env.STRIPE_BASIC_PRICE_ID) { if (
(env.STRIPE_BASIC_PRICE_ID &&
priceIds.includes(env.STRIPE_BASIC_PRICE_ID)) ||
(env.STRIPE_LEGACY_BASIC_PRICE_ID &&
priceIds.includes(env.STRIPE_LEGACY_BASIC_PRICE_ID))
) {
return "BASIC"; return "BASIC";
} }
@@ -129,11 +142,16 @@ export async function syncStripeData(customerId: string) {
return; return;
} }
const priceIds = subscription.items.data
.map((item) => item.price?.id)
.filter((id): id is string => Boolean(id));
await db.subscription.upsert({ await db.subscription.upsert({
where: { id: subscription.id }, where: { id: subscription.id },
update: { update: {
status: subscription.status, status: subscription.status,
priceId: subscription.items.data[0]?.price?.id || "", priceId: subscription.items.data[0]?.price?.id || "",
priceIds: priceIds,
currentPeriodEnd: new Date( currentPeriodEnd: new Date(
subscription.items.data[0]?.current_period_end * 1000 subscription.items.data[0]?.current_period_end * 1000
), ),
@@ -150,6 +168,7 @@ export async function syncStripeData(customerId: string) {
id: subscription.id, id: subscription.id,
status: subscription.status, status: subscription.status,
priceId: subscription.items.data[0]?.price?.id || "", priceId: subscription.items.data[0]?.price?.id || "",
priceIds: priceIds,
currentPeriodEnd: new Date( currentPeriodEnd: new Date(
subscription.items.data[0]?.current_period_end * 1000 subscription.items.data[0]?.current_period_end * 1000
), ),
@@ -167,7 +186,7 @@ export async function syncStripeData(customerId: string) {
await db.team.update({ await db.team.update({
where: { id: team.id }, where: { id: team.id },
data: { data: {
plan: getPlanFromPriceId(subscription.items.data[0]?.price?.id || ""), plan: getPlanFromPriceIds(priceIds),
isActive: subscription.status === "active", isActive: subscription.status === "active",
}, },
}); });

View File

@@ -0,0 +1,83 @@
import { CampaignStatus, type ContactBook } from "@prisma/client";
import { db } from "../db";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
export async function getContactBooks(teamId: number, search?: string) {
return db.contactBook.findMany({
where: {
teamId,
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
},
include: {
_count: {
select: { contacts: true },
},
},
});
}
export async function createContactBook(teamId: number, name: string) {
const { isLimitReached, reason } =
await LimitService.checkContactBookLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Contact book limit reached",
});
}
return db.contactBook.create({
data: {
name,
teamId,
properties: {},
},
});
}
export async function getContactBookDetails(contactBookId: string) {
const [totalContacts, unsubscribedContacts, campaigns] = await Promise.all([
db.contact.count({
where: { contactBookId },
}),
db.contact.count({
where: { contactBookId, subscribed: false },
}),
db.campaign.findMany({
where: {
contactBookId,
status: CampaignStatus.SENT,
},
orderBy: {
createdAt: "desc",
},
take: 2,
}),
]);
return {
totalContacts,
unsubscribedContacts,
campaigns,
};
}
export async function updateContactBook(
contactBookId: string,
data: {
name?: string;
properties?: Record<string, string>;
emoji?: string;
}
) {
return db.contactBook.update({
where: { id: contactBookId },
data,
});
}
export async function deleteContactBook(contactBookId: string) {
return db.contactBook.delete({ where: { id: contactBookId } });
}

View File

@@ -6,6 +6,7 @@ import { db } from "~/server/db";
import { SesSettingsService } from "./ses-settings-service"; import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error"; import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log"; import { logger } from "../logger/log";
import { LimitService } from "./limit-service";
const dnsResolveTxt = util.promisify(dns.resolveTxt); const dnsResolveTxt = util.promisify(dns.resolveTxt);
@@ -58,7 +59,7 @@ export async function createDomain(
teamId: number, teamId: number,
name: string, name: string,
region: string, region: string,
sesTenantId?: string, sesTenantId?: string
) { ) {
const domainStr = tldts.getDomain(name); const domainStr = tldts.getDomain(name);
@@ -74,6 +75,16 @@ export async function createDomain(
throw new Error("Ses setting not found"); throw new Error("Ses setting not found");
} }
const { isLimitReached, reason } =
await LimitService.checkDomainLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Domain limit reached",
});
}
const subdomain = tldts.getSubdomain(name); const subdomain = tldts.getSubdomain(name);
const publicKey = await ses.addDomain(name, region, sesTenantId); const publicKey = await ses.addDomain(name, region, sesTenantId);
@@ -105,7 +116,7 @@ export async function getDomain(id: number) {
if (domain.isVerifying) { if (domain.isVerifying) {
const domainIdentity = await ses.getDomainIdentity( const domainIdentity = await ses.getDomainIdentity(
domain.name, domain.name,
domain.region, domain.region
); );
const dkimStatus = domainIdentity.DkimAttributes?.Status; const dkimStatus = domainIdentity.DkimAttributes?.Status;
@@ -150,7 +161,7 @@ export async function getDomain(id: number) {
export async function updateDomain( export async function updateDomain(
id: number, id: number,
data: { clickTracking?: boolean; openTracking?: boolean }, data: { clickTracking?: boolean; openTracking?: boolean }
) { ) {
return db.domain.update({ return db.domain.update({
where: { id }, where: { id },
@@ -170,7 +181,7 @@ export async function deleteDomain(id: number) {
const deleted = await ses.deleteDomain( const deleted = await ses.deleteDomain(
domain.name, domain.name,
domain.region, domain.region,
domain.sesTenantId ?? undefined, domain.sesTenantId ?? undefined
); );
if (!deleted) { if (!deleted) {

View File

@@ -0,0 +1,160 @@
import { PLAN_LIMITS, LimitReason } from "~/lib/constants/plans";
import { db } from "../db";
import { getThisMonthUsage } from "./usage-service";
function isLimitExceeded(current: number, limit: number): boolean {
if (limit === -1) return false; // unlimited
return current >= limit;
}
export class LimitService {
static async checkDomainLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
domains: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].domains;
if (isLimitExceeded(team._count.domains, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.DOMAIN,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkContactBookLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
contactBooks: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].contactBooks;
if (isLimitExceeded(team._count.contactBooks, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.CONTACT_BOOK,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkTeamMemberLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
teamUsers: true,
},
});
if (!team) {
throw new Error("Team not found");
}
const limit = PLAN_LIMITS[team.plan].teamMembers;
if (isLimitExceeded(team.teamUsers.length, limit)) {
return {
isLimitReached: true,
limit,
reason: LimitReason.TEAM_MEMBER,
};
}
return {
isLimitReached: false,
limit,
};
}
static async checkEmailLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
// FREE plan has hard limits; paid plans are unlimited (-1)
if (team.plan === "FREE") {
const usage = await getThisMonthUsage(teamId);
const monthlyUsage = usage.month.reduce(
(acc, curr) => acc + curr.sent,
0,
);
const dailyUsage = usage.day.reduce((acc, curr) => acc + curr.sent, 0);
const monthlyLimit = PLAN_LIMITS[team.plan].emailsPerMonth;
const dailyLimit = PLAN_LIMITS[team.plan].emailsPerDay;
if (isLimitExceeded(monthlyUsage, monthlyLimit)) {
return {
isLimitReached: true,
limit: monthlyLimit,
reason: LimitReason.EMAIL,
};
}
if (isLimitExceeded(dailyUsage, dailyLimit)) {
return {
isLimitReached: true,
limit: dailyLimit,
reason: LimitReason.EMAIL,
};
}
}
return {
isLimitReached: false,
limit: PLAN_LIMITS[team.plan].emailsPerMonth,
};
}
}

View File

@@ -0,0 +1,293 @@
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import { db } from "~/server/db";
import { sendTeamInviteEmail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
import type { Team, TeamInvite } from "@prisma/client";
import { LimitService } from "./limit-service";
import { UnsendApiError } from "../public-api/api-error";
export class TeamService {
static async createTeam(
userId: number,
name: string
): Promise<Team | undefined> {
const teams = await db.team.findMany({
where: {
teamUsers: {
some: {
userId: userId,
},
},
},
});
if (teams.length > 0) {
logger.info({ userId }, "User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return db.team.create({
data: {
name,
teamUsers: {
create: {
userId,
role: "ADMIN",
},
},
},
});
}
static async getUserTeams(userId: number) {
return db.team.findMany({
where: {
teamUsers: {
some: {
userId: userId,
},
},
},
include: {
teamUsers: {
where: {
userId: userId,
},
},
},
});
}
static async getTeamUsers(teamId: number) {
return db.teamUser.findMany({
where: {
teamId,
},
include: {
user: true,
},
});
}
static async getTeamInvites(teamId: number) {
return db.teamInvite.findMany({
where: {
teamId,
},
});
}
static async createTeamInvite(
teamId: number,
email: string,
role: "MEMBER" | "ADMIN",
teamName: string,
sendEmail: boolean = true
): Promise<TeamInvite> {
if (!email) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email is required",
});
}
const { isLimitReached, reason } =
await LimitService.checkTeamMemberLimit(teamId);
if (isLimitReached) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Team invite limit reached",
});
}
const user = await db.user.findUnique({
where: {
email,
},
include: {
teamUsers: true,
},
});
if (user && user.teamUsers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User already part of a team",
});
}
const teamInvite = await db.teamInvite.create({
data: {
teamId,
email,
role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
if (sendEmail) {
await sendTeamInviteEmail(email, teamUrl, teamName);
}
return teamInvite;
}
static async updateTeamUserRole(
teamId: number,
userId: string,
role: "MEMBER" | "ADMIN"
) {
const teamUser = await db.teamUser.findFirst({
where: {
teamId,
userId: Number(userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await db.teamUser.count({
where: {
teamId,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return db.teamUser.update({
where: {
teamId_userId: {
teamId,
userId: Number(userId),
},
},
data: {
role,
},
});
}
static async deleteTeamUser(
teamId: number,
userId: string,
requestorRole: string,
requestorId: number
) {
const teamUser = await db.teamUser.findFirst({
where: {
teamId,
userId: Number(userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (requestorRole !== "ADMIN" && requestorId !== Number(userId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this team member",
});
}
// Check if this is the last admin
const adminCount = await db.teamUser.count({
where: {
teamId,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return db.teamUser.delete({
where: {
teamId_userId: {
teamId,
userId: Number(userId),
},
},
});
}
static async resendTeamInvite(inviteId: string, teamName: string) {
const invite = await db.teamInvite.findUnique({
where: {
id: inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
await sendTeamInviteEmail(invite.email, teamUrl, teamName);
return { success: true };
}
static async deleteTeamInvite(teamId: number, inviteId: string) {
const invite = await db.teamInvite.findFirst({
where: {
teamId,
id: {
equals: inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return db.teamInvite.delete({
where: {
teamId_email: {
teamId,
email: invite.email,
},
},
});
}
}

View File

@@ -0,0 +1,63 @@
import { EmailUsageType, Subscription } from "@prisma/client";
import { db } from "../db";
import { format } from "date-fns";
/**
* Gets the monthly and daily usage for a team
* @param teamId - The team ID to get usage for
* @param db - Prisma database client
* @param subscription - Optional subscription to determine billing period start
* @returns Object containing month and day usage arrays
*/
export async function getThisMonthUsage(teamId: number) {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
}
let subscription: Subscription | null = null;
const isPaidPlan = team.plan !== "FREE";
if (isPaidPlan) {
subscription = await db.subscription.findFirst({
where: { teamId: team.id },
orderBy: { status: "asc" },
});
}
const isoStartDate = subscription?.currentPeriodStart
? format(subscription.currentPeriodStart, "yyyy-MM-dd")
: format(new Date(), "yyyy-MM-01"); // First day of current month
const today = format(new Date(), "yyyy-MM-dd");
const [monthUsage, dayUsage] = await Promise.all([
// Get month usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" >= ${isoStartDate}
GROUP BY "type"
`,
// Get today's usage
db.$queryRaw<Array<{ type: EmailUsageType; sent: number }>>`
SELECT
type,
SUM(sent)::integer AS sent
FROM "DailyEmailUsage"
WHERE "teamId" = ${team.id}
AND "date" = ${today}
GROUP BY "type"
`,
]);
return {
month: monthUsage,
day: dayUsage,
};
}

View File

@@ -0,0 +1,20 @@
import { create } from "zustand";
import { LimitReason } from "~/lib/constants/plans";
interface UpgradeModalStore {
isOpen: boolean;
reason?: LimitReason;
action: {
openModal: (reason?: LimitReason) => void;
closeModal: () => void;
};
}
export const useUpgradeModalStore = create<UpgradeModalStore>((set) => ({
isOpen: false,
reason: undefined,
action: {
openModal: (reason?: LimitReason) => set({ isOpen: true, reason }),
closeModal: () => set({ isOpen: false, reason: undefined }),
},
}));

25
pnpm-lock.yaml generated
View File

@@ -281,6 +281,9 @@ importers:
zod: zod:
specifier: ^3.24.3 specifier: ^3.24.3
version: 3.24.3 version: 3.24.3
zustand:
specifier: ^5.0.8
version: 5.0.8(@types/react@19.1.2)(react@19.1.0)
devDependencies: devDependencies:
'@next/eslint-plugin-next': '@next/eslint-plugin-next':
specifier: ^15.3.1 specifier: ^15.3.1
@@ -18071,5 +18074,27 @@ packages:
/zod@3.24.3: /zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
/zustand@5.0.8(@types/react@19.1.2)(react@19.1.0):
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
dependencies:
'@types/react': 19.1.2
react: 19.1.0
dev: false
/zwitch@2.0.4: /zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}