-
-
You're on the Waitlist!
-
- Hang tight, we'll get to you as soon as possible.
-
+
+
+
+
+
+
+
+
You're on the waitlist
+
+ Share a bit more context so we can prioritize your access.
+
+
+
+
+
);
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index 155011f..8124572 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -12,6 +12,7 @@ import { invitationRouter } from "./routers/invitiation";
import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
+import { waitlistRouter } from "./routers/waitlist";
/**
* This is the primary router for your server.
@@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({
dashboard: dashboardRouter,
suppression: suppressionRouter,
limits: limitsRouter,
+ waitlist: waitlistRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts
index dd78eee..9d08ae3 100644
--- a/apps/web/src/server/api/routers/admin.ts
+++ b/apps/web/src/server/api/routers/admin.ts
@@ -4,6 +4,46 @@ import { env } from "~/env";
import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
+import { db } from "~/server/db";
+
+const waitlistUserSelection = {
+ id: true,
+ email: true,
+ name: true,
+ isWaitlisted: true,
+ createdAt: true,
+} as const;
+
+const teamAdminSelection = {
+ id: true,
+ name: true,
+ plan: true,
+ apiRateLimit: true,
+ dailyEmailLimit: true,
+ isBlocked: true,
+ billingEmail: true,
+ createdAt: true,
+ teamUsers: {
+ select: {
+ role: true,
+ user: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ },
+ },
+ },
+ domains: {
+ select: {
+ id: true,
+ name: true,
+ status: true,
+ isVerifying: true,
+ },
+ },
+} as const;
export const adminRouter = createTRPCRouter({
getSesSettings: adminProcedure.query(async () => {
@@ -66,4 +106,116 @@ export const adminRouter = createTRPCRouter({
input.region ?? env.AWS_DEFAULT_REGION,
);
}),
+
+ findUserByEmail: adminProcedure
+ .input(
+ z.object({
+ email: z
+ .string()
+ .email()
+ .transform((value) => value.toLowerCase()),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const user = await db.user.findUnique({
+ where: { email: input.email },
+ select: waitlistUserSelection,
+ });
+
+ return user ?? null;
+ }),
+
+ updateUserWaitlist: adminProcedure
+ .input(
+ z.object({
+ userId: z.number(),
+ isWaitlisted: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const updatedUser = await db.user.update({
+ where: { id: input.userId },
+ data: { isWaitlisted: input.isWaitlisted },
+ select: waitlistUserSelection,
+ });
+
+ return updatedUser;
+ }),
+
+ findTeam: adminProcedure
+ .input(
+ z.object({
+ query: z
+ .string({ required_error: "Search query is required" })
+ .trim()
+ .min(1, "Search query is required"),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const query = input.query.trim();
+
+ let numericId: number | null = null;
+ if (/^\d+$/.test(query)) {
+ numericId = Number(query);
+ }
+
+ let team = numericId
+ ? await db.team.findUnique({
+ where: { id: numericId },
+ select: teamAdminSelection,
+ })
+ : null;
+
+ if (!team) {
+ team = await db.team.findFirst({
+ where: {
+ OR: [
+ { name: { equals: query, mode: "insensitive" } },
+ { billingEmail: { equals: query, mode: "insensitive" } },
+ {
+ teamUsers: {
+ some: {
+ user: {
+ email: { equals: query, mode: "insensitive" },
+ },
+ },
+ },
+ },
+ {
+ domains: {
+ some: {
+ name: { equals: query, mode: "insensitive" },
+ },
+ },
+ },
+ ],
+ },
+ select: teamAdminSelection,
+ });
+ }
+
+ return team ?? null;
+ }),
+
+ updateTeamSettings: adminProcedure
+ .input(
+ z.object({
+ teamId: z.number(),
+ apiRateLimit: z.number().int().min(1).max(10_000),
+ dailyEmailLimit: z.number().int().min(0).max(10_000_000),
+ isBlocked: z.boolean(),
+ plan: z.enum(["FREE", "BASIC"]),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const { teamId, ...data } = input;
+
+ const updatedTeam = await db.team.update({
+ where: { id: teamId },
+ data,
+ select: teamAdminSelection,
+ });
+
+ return updatedTeam;
+ }),
});
diff --git a/apps/web/src/server/api/routers/waitlist.ts b/apps/web/src/server/api/routers/waitlist.ts
new file mode 100644
index 0000000..bafa8f4
--- /dev/null
+++ b/apps/web/src/server/api/routers/waitlist.ts
@@ -0,0 +1,107 @@
+import { TRPCError } from "@trpc/server";
+
+import { env } from "~/env";
+import { authedProcedure, createTRPCRouter } from "~/server/api/trpc";
+import { logger } from "~/server/logger/log";
+import { sendMail } from "~/server/mailer";
+import { getRedis } from "~/server/redis";
+import {
+ WAITLIST_EMAIL_TYPES,
+ waitlistSubmissionSchema,
+} from "~/app/wait-list/schema";
+
+const RATE_LIMIT_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
+const RATE_LIMIT_MAX_ATTEMPTS = 3;
+
+const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> = {
+ transactional: "Transactional",
+ marketing: "Marketing",
+};
+
+function escapeHtml(input: string) {
+ return input
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export const waitlistRouter = createTRPCRouter({
+ submitRequest: authedProcedure
+ .input(waitlistSubmissionSchema)
+ .mutation(async ({ ctx, input }) => {
+ if (!ctx.session || !ctx.session.user) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ const { user } = ctx.session;
+
+ const founderEmail = env.FOUNDER_EMAIL ?? env.ADMIN_EMAIL;
+
+ if (!founderEmail) {
+ logger.error("FOUNDER_EMAIL/ADMIN_EMAIL is not configured; skipping waitlist notification");
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Waitlist notifications are not configured",
+ });
+ }
+
+ const redis = getRedis();
+ const rateKey = `waitlist:requests:${user.id}`;
+
+ const currentCountRaw = await redis.get(rateKey);
+ const currentCount = currentCountRaw ? Number(currentCountRaw) : 0;
+
+ if (Number.isNaN(currentCount)) {
+ logger.warn({ currentCountRaw }, "Unexpected rate limit counter value");
+ } else if (currentCount >= RATE_LIMIT_MAX_ATTEMPTS) {
+ throw new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: "You have reached the waitlist request limit. Please try later.",
+ });
+ }
+
+ const pipeline = redis.multi();
+ pipeline.incr(rateKey);
+ if (!currentCountRaw) {
+ pipeline.expire(rateKey, RATE_LIMIT_WINDOW_SECONDS);
+ }
+ await pipeline.exec();
+
+ const typesLabel = input.emailTypes
+ .map((type) => EMAIL_TYPE_LABEL[type])
+ .join(", ");
+
+ const escapedDescription = escapeHtml(input.description);
+ const escapedDomain = escapeHtml(input.domain);
+ const subject = `Waitlist request from ${user.email ?? "unknown user"}`;
+
+ const textBody = `A waitlisted user submitted a request:\n\nEmail: ${
+ user.email ?? "Unknown"
+ }\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\n\nDescription:\n${input.description}`;
+
+ const htmlBody = `
+
A waitlisted user submitted a request.
+
+ - Email: ${escapeHtml(user.email ?? "Unknown")}
+ - Domain: ${escapedDomain}
+ - Interested emails: ${escapeHtml(typesLabel)}
+
+
Description
+
${escapedDescription}
+ `;
+
+ await sendMail(founderEmail, subject, textBody, htmlBody, user.email ?? undefined);
+
+ logger.info(
+ {
+ userId: user.id,
+ email: user.email,
+ },
+ "Waitlist request submitted"
+ );
+
+ return { ok: true };
+ }),
+});
diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts
index c86d4dc..5f741b1 100644
--- a/apps/web/src/server/api/trpc.ts
+++ b/apps/web/src/server/api/trpc.ts
@@ -90,6 +90,24 @@ export const createTRPCRouter = t.router;
*/
export const publicProcedure = t.procedure;
+/**
+ * Authenticated (session-required) procedure
+ *
+ * Ensures a session exists but does not enforce waitlist status. Useful for flows where waitlisted
+ * users should still have access (e.g., waitlist management).
+ */
+export const authedProcedure = t.procedure.use(({ ctx, next }) => {
+ if (!ctx.session || !ctx.session.user) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ return next({
+ ctx: {
+ session: { ...ctx.session, user: ctx.session.user },
+ },
+ });
+});
+
/**
* Protected (authenticated) procedure
*
@@ -98,17 +116,12 @@ export const publicProcedure = t.procedure;
*
* @see https://trpc.io/docs/procedures
*/
-export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
- if (!ctx.session || !ctx.session.user || ctx.session.user.isWaitlisted) {
+export const protectedProcedure = authedProcedure.use(({ ctx, next }) => {
+ if (ctx.session.user.isWaitlisted) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
- return next({
- ctx: {
- // infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
- },
- });
+ return next();
});
export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {