feat: make billing better (#203)
This commit is contained in:
@@ -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",
|
||||||
|
@@ -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;
|
@@ -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?
|
||||||
|
@@ -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..."
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
@@ -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]"
|
||||||
>
|
>
|
||||||
|
@@ -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();
|
||||||
|
67
apps/web/src/components/payments/UpgradeModal.tsx
Normal file
67
apps/web/src/components/payments/UpgradeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@@ -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,
|
||||||
|
34
apps/web/src/lib/constants/plans.ts
Normal file
34
apps/web/src/lib/constants/plans.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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 }) => {
|
||||||
|
@@ -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
|
||||||
|
28
apps/web/src/server/api/routers/limits.ts
Normal file
28
apps/web/src/server/api/routers/limits.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
83
apps/web/src/server/service/contact-book-service.ts
Normal file
83
apps/web/src/server/service/contact-book-service.ts
Normal 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 } });
|
||||||
|
}
|
@@ -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) {
|
||||||
|
160
apps/web/src/server/service/limit-service.ts
Normal file
160
apps/web/src/server/service/limit-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
293
apps/web/src/server/service/team-service.ts
Normal file
293
apps/web/src/server/service/team-service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
63
apps/web/src/server/service/usage-service.ts
Normal file
63
apps/web/src/server/service/usage-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
20
apps/web/src/store/upgradeModalStore.ts
Normal file
20
apps/web/src/store/upgradeModalStore.ts
Normal 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
25
pnpm-lock.yaml
generated
@@ -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==}
|
||||||
|
Reference in New Issue
Block a user