From 6f5f81bd0a5d41e6a9732a30ea20515573eafdf4 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Thu, 26 Feb 2026 03:04:24 +0000 Subject: [PATCH 1/2] feat(web): introduce backward-compatible public IDs --- .../migration.sql | 23 +++++ apps/web/prisma/schema.prisma | 4 + .../(dashboard)/domains/[domainId]/page.tsx | 12 ++- .../app/(dashboard)/domains/add-domain.tsx | 4 +- .../app/(dashboard)/domains/domain-list.tsx | 8 +- apps/web/src/lib/zod/domain-schema.ts | 22 +++-- apps/web/src/server/api/routers/admin.ts | 5 ++ .../routers/campaign-security.trpc.test.ts | 1 + apps/web/src/server/api/routers/campaign.ts | 3 + apps/web/src/server/api/routers/domain.ts | 33 +++----- apps/web/src/server/api/routers/template.ts | 3 + apps/web/src/server/api/trpc.ts | 36 ++++++-- apps/web/src/server/auth.ts | 12 +-- apps/web/src/server/id.ts | 84 +++++++++++++++++++ .../public-api/api/domains/delete-domain.ts | 32 +++++-- .../public-api/api/domains/get-domain.ts | 31 +++++-- .../public-api/api/domains/verify-domain.ts | 56 ++++++++----- apps/web/src/server/service/api-service.ts | 8 +- .../src/server/service/campaign-service.ts | 53 +++++++----- .../server/service/contact-book-service.ts | 4 +- .../web/src/server/service/contact-service.ts | 2 + apps/web/src/server/service/domain-service.ts | 33 ++++++++ .../src/server/service/email-queue-service.ts | 55 ++++++------ apps/web/src/server/service/email-service.ts | 60 +++++++------ .../web/src/server/service/ses-hook-parser.ts | 13 ++- .../server/service/ses-settings-service.ts | 28 ++++--- .../src/server/service/suppression-service.ts | 51 +++++------ apps/web/src/server/service/team-service.ts | 3 + .../web/src/server/service/webhook-service.ts | 4 + apps/web/src/test/factories/core.ts | 3 + packages/sdk/src/domain.ts | 6 +- 31 files changed, 475 insertions(+), 217 deletions(-) create mode 100644 apps/web/prisma/migrations/20260226120000_add_public_ids/migration.sql create mode 100644 apps/web/src/server/id.ts diff --git a/apps/web/prisma/migrations/20260226120000_add_public_ids/migration.sql b/apps/web/prisma/migrations/20260226120000_add_public_ids/migration.sql new file mode 100644 index 00000000..316bb1fd --- /dev/null +++ b/apps/web/prisma/migrations/20260226120000_add_public_ids/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "publicId" TEXT; + +-- AlterTable +ALTER TABLE "Domain" ADD COLUMN "publicId" TEXT; + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "publicId" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "publicId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_publicId_key" ON "ApiKey"("publicId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Domain_publicId_key" ON "Domain"("publicId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_publicId_key" ON "Team"("publicId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_publicId_key" ON "User"("publicId"); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 9ef292b6..2d60df24 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -80,6 +80,7 @@ model VerificationToken { model User { id Int @id @default(autoincrement()) + publicId String? @unique name String? email String? @unique emailVerified DateTime? @@ -100,6 +101,7 @@ enum Plan { model Team { id Int @id @default(autoincrement()) + publicId String? @unique name String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -181,6 +183,7 @@ enum DomainStatus { model Domain { id Int @id @default(autoincrement()) + publicId String? @unique name String @unique teamId Int status DomainStatus @default(PENDING) @@ -209,6 +212,7 @@ enum ApiPermission { model ApiKey { id Int @id @default(autoincrement()) + publicId String? @unique clientId String @unique tokenHash String partialToken String diff --git a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx index be4691b9..c7d03d39 100644 --- a/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -42,7 +42,7 @@ export default function DomainItemPage({ const domainQuery = api.domain.getDomain.useQuery( { - id: Number(domainId), + id: domainId, }, { refetchInterval: (q) => (q?.state.data?.isVerifying ? 10000 : false), @@ -54,7 +54,7 @@ export default function DomainItemPage({ const handleVerify = () => { verifyQuery.mutate( - { id: Number(domainId) }, + { id: domainId }, { onSettled: () => { domainQuery.refetch(); @@ -154,7 +154,9 @@ export default function DomainItemPage({ /> {record.ttl} - {record.priority ?? ""} + + {record.priority ?? ""} + @@ -248,7 +250,9 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => { ); }; -const DnsVerificationStatus: React.FC<{ status: DomainStatus }> = ({ status }) => { +const DnsVerificationStatus: React.FC<{ status: DomainStatus }> = ({ + status, +}) => { let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color switch (status) { case DomainStatus.SUCCESS: diff --git a/apps/web/src/app/(dashboard)/domains/add-domain.tsx b/apps/web/src/app/(dashboard)/domains/add-domain.tsx index 1987f1f4..cf161dac 100644 --- a/apps/web/src/app/(dashboard)/domains/add-domain.tsx +++ b/apps/web/src/app/(dashboard)/domains/add-domain.tsx @@ -101,13 +101,13 @@ export default function AddDomain() { { onSuccess: async (data) => { utils.domain.domains.invalidate(); - await router.push(`/domains/${data.id}`); + await router.push(`/domains/${data.publicId ?? data.id}`); setOpen(false); }, onError: async (error) => { toast.error(error.message); }, - } + }, ); } diff --git a/apps/web/src/app/(dashboard)/domains/domain-list.tsx b/apps/web/src/app/(dashboard)/domains/domain-list.tsx index a2ef5f4e..a4439183 100644 --- a/apps/web/src/app/(dashboard)/domains/domain-list.tsx +++ b/apps/web/src/app/(dashboard)/domains/domain-list.tsx @@ -40,7 +40,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => { const utils = api.useUtils(); const [clickTracking, setClickTracking] = React.useState( - domain.clickTracking + domain.clickTracking, ); const [openTracking, setOpenTracking] = React.useState(domain.openTracking); @@ -52,7 +52,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => { onSuccess: () => { utils.domain.domains.invalidate(); }, - } + }, ); } @@ -64,7 +64,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => { onSuccess: () => { utils.domain.domains.invalidate(); }, - } + }, ); } @@ -75,7 +75,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
{domain.name} diff --git a/apps/web/src/lib/zod/domain-schema.ts b/apps/web/src/lib/zod/domain-schema.ts index 4d81ac18..c5f7e0e1 100644 --- a/apps/web/src/lib/zod/domain-schema.ts +++ b/apps/web/src/lib/zod/domain-schema.ts @@ -8,18 +8,12 @@ export const DomainDnsRecordSchema = z.object({ description: "DNS record type", example: "TXT", }), - name: z - .string() - .openapi({ description: "DNS record name", example: "mail" }), - value: z - .string() - .openapi({ - description: "DNS record value", - example: "v=spf1 include:amazonses.com ~all", - }), - ttl: z - .string() - .openapi({ description: "DNS record TTL", example: "Auto" }), + name: z.string().openapi({ description: "DNS record name", example: "mail" }), + value: z.string().openapi({ + description: "DNS record value", + example: "v=spf1 include:amazonses.com ~all", + }), + ttl: z.string().openapi({ description: "DNS record TTL", example: "Auto" }), priority: z .string() .nullish() @@ -33,6 +27,10 @@ export const DomainDnsRecordSchema = z.object({ export const DomainSchema = z.object({ id: z.number().openapi({ description: "The ID of the domain", example: 1 }), + publicId: z.string().nullable().optional().openapi({ + description: "Public domain identifier", + example: "dom_3NfPq7hK9a2Tj6Rx", + }), name: z .string() .openapi({ description: "The name of the domain", example: "example.com" }), diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index 4ca82b2a..7c2d8f03 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -33,6 +33,7 @@ function formatDisplayNameFromEmail(email: string) { const teamAdminSelection = { id: true, + publicId: true, name: true, plan: true, apiRateLimit: true, @@ -55,6 +56,7 @@ const teamAdminSelection = { domains: { select: { id: true, + publicId: true, name: true, status: true, isVerifying: true, @@ -347,6 +349,9 @@ export const adminRouter = createTRPCRouter({ OR: [ { name: { equals: query, mode: "insensitive" } }, { billingEmail: { equals: query, mode: "insensitive" } }, + { + publicId: { equals: query, mode: "insensitive" }, + }, { teamUsers: { some: { diff --git a/apps/web/src/server/api/routers/campaign-security.trpc.test.ts b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts index 1d60a3f2..9cdb0187 100644 --- a/apps/web/src/server/api/routers/campaign-security.trpc.test.ts +++ b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts @@ -154,6 +154,7 @@ describe("campaignRouter.duplicateCampaign", () => { expect(mockDb.campaign.create).toHaveBeenCalledWith({ data: { + id: expect.stringMatching(/^cmp_/), name: "Weekly update (Copy)", from: "Team ", replyTo: ["support@example.com"], diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 6e385c42..3b33c29a 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -17,6 +17,7 @@ import { getDocumentUploadUrl, isStorageConfigured, } from "~/server/service/storage-service"; +import { createCampaignId } from "~/server/id"; const statuses = Object.values(CampaignStatus) as [CampaignStatus]; @@ -102,6 +103,7 @@ export const campaignRouter = createTRPCRouter({ const campaign = await db.campaign.create({ data: { + id: createCampaignId(), ...input, teamId: team.id, domainId: domain.id, @@ -241,6 +243,7 @@ export const campaignRouter = createTRPCRouter({ async ({ ctx: { db, team, campaign } }) => { const newCampaign = await db.campaign.create({ data: { + id: createCampaignId(), name: `${campaign.name} (Copy)`, from: campaign.from, replyTo: campaign.replyTo, diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index 848d24aa..5f9f9299 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -6,7 +6,6 @@ import { protectedProcedure, domainProcedure, } from "~/server/api/trpc"; -import { db } from "~/server/db"; import { createDomain, deleteDomain, @@ -30,13 +29,13 @@ export const domainRouter = createTRPCRouter({ ctx.team.id, input.name, input.region, - ctx.team.sesTenantId ?? undefined + ctx.team.sesTenantId ?? undefined, ); }), - startVerification: domainProcedure.mutation(async ({ ctx, input }) => { + startVerification: domainProcedure.mutation(async ({ ctx }) => { await ctx.db.domain.update({ - where: { id: input.id }, + where: { id: ctx.domain.id }, data: { isVerifying: true }, }); }), @@ -45,8 +44,8 @@ export const domainRouter = createTRPCRouter({ return getDomains(ctx.team.id); }), - getDomain: domainProcedure.query(async ({ input, ctx }) => { - return getDomain(input.id, ctx.team.id); + getDomain: domainProcedure.query(async ({ ctx }) => { + return getDomain(ctx.domain.id, ctx.team.id); }), updateDomain: domainProcedure @@ -54,17 +53,17 @@ export const domainRouter = createTRPCRouter({ z.object({ clickTracking: z.boolean().optional(), openTracking: z.boolean().optional(), - }) + }), ) - .mutation(async ({ input }) => { - return updateDomain(input.id, { + .mutation(async ({ input, ctx }) => { + return updateDomain(ctx.domain.id, { clickTracking: input.clickTracking, openTracking: input.openTracking, }); }), - deleteDomain: domainProcedure.mutation(async ({ input }) => { - await deleteDomain(input.id); + deleteDomain: domainProcedure.mutation(async ({ ctx }) => { + await deleteDomain(ctx.domain.id); return { success: true }; }), @@ -73,17 +72,9 @@ export const domainRouter = createTRPCRouter({ ctx: { session: { user }, team, + domain, }, - input, }) => { - const domain = await db.domain.findFirst({ - where: { id: input.id, teamId: team.id }, - }); - - if (!domain) { - throw new Error("Domain not found"); - } - if (!user.email) { throw new Error("User email not found"); } @@ -96,6 +87,6 @@ export const domainRouter = createTRPCRouter({ text: "hello,\n\nuseSend is the best open source sending platform\n\ncheck out https://usesend.com", html: "

hello,

useSend is the best open source sending platform

check out usesend.com", }); - } + }, ), }); diff --git a/apps/web/src/server/api/routers/template.ts b/apps/web/src/server/api/routers/template.ts index 6e90e686..3fa52f3a 100644 --- a/apps/web/src/server/api/routers/template.ts +++ b/apps/web/src/server/api/routers/template.ts @@ -13,6 +13,7 @@ import { getDocumentUploadUrl, isStorageConfigured, } from "~/server/service/storage-service"; +import { createTemplateId } from "~/server/id"; export const templateRouter = createTRPCRouter({ getTemplates: teamProcedure @@ -64,6 +65,7 @@ export const templateRouter = createTRPCRouter({ .mutation(async ({ ctx: { db, team }, input }) => { const template = await db.template.create({ data: { + id: createTemplateId(), ...input, teamId: team.id, }, @@ -134,6 +136,7 @@ export const templateRouter = createTRPCRouter({ async ({ ctx: { db, team, template }, input }) => { const newTemplate = await db.template.create({ data: { + id: createTemplateId(), name: `${template.name} (Copy)`, subject: template.subject, content: template.content, diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index 5f741b11..14a51356 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -16,6 +16,7 @@ import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; import { getChildLogger, logger, withLogger } from "../logger/log"; import { randomUUID } from "crypto"; +import { parseNumericId } from "~/server/id"; /** * 1. CONTEXT @@ -147,7 +148,7 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => { session: { ...ctx.session, user: ctx.session.user }, }, }); - } + }, ); }); @@ -163,11 +164,30 @@ export const teamAdminProcedure = teamProcedure.use(async ({ ctx, next }) => { }); export const domainProcedure = teamProcedure - .input(z.object({ id: z.number() })) + .input( + z.object({ + id: z.union([z.number().int().positive(), z.string().min(1)]), + }), + ) .use(async ({ ctx, next, input }) => { - const domain = await db.domain.findUnique({ - where: { id: input.id, teamId: ctx.team.id }, - }); + const domainId = + typeof input.id === "number" ? input.id : parseNumericId(input.id); + const domainPublicId = typeof input.id === "string" ? input.id : null; + + const domain = + domainId !== null + ? await db.domain.findUnique({ + where: { id: domainId, teamId: ctx.team.id }, + }) + : domainPublicId + ? await db.domain.findFirst({ + where: { + publicId: domainPublicId, + teamId: ctx.team.id, + }, + }) + : null; + if (!domain) { throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" }); } @@ -205,7 +225,7 @@ export const contactBookProcedure = teamProcedure .input( z.object({ contactBookId: z.string(), - }) + }), ) .use(async ({ ctx, next, input }) => { const contactBook = await db.contactBook.findUnique({ @@ -225,7 +245,7 @@ export const campaignProcedure = teamProcedure .input( z.object({ campaignId: z.string(), - }) + }), ) .use(async ({ ctx, next, input }) => { const campaign = await db.campaign.findUnique({ @@ -245,7 +265,7 @@ export const templateProcedure = teamProcedure .input( z.object({ templateId: z.string(), - }) + }), ) .use(async ({ ctx, next, input }) => { const template = await db.template.findUnique({ diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 1f53f4c5..ed2e4156 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -13,6 +13,7 @@ import { Provider } from "next-auth/providers/index"; import { sendSignUpEmail } from "~/server/mailer"; import { env } from "~/env"; import { db } from "~/server/db"; +import { createUserPublicId } from "~/server/id"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -60,7 +61,7 @@ function getProviders() { scope: "read:user user:email", }, }, - }) + }), ); } @@ -70,7 +71,7 @@ function getProviders() { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, allowDangerousEmailAccountLinking: true, - }) + }), ); } @@ -84,7 +85,7 @@ function getProviders() { async generateVerificationToken() { return Math.random().toString(36).substring(2, 7).toLowerCase(); }, - }) + }), ); } @@ -120,6 +121,7 @@ export const authOptions: NextAuthOptions = { events: { createUser: async ({ user }) => { let invitesAvailable = false; + const publicId = createUserPublicId(); if (user.email) { const invites = await db.teamInvite.findMany({ @@ -136,12 +138,12 @@ export const authOptions: NextAuthOptions = { ) { await db.user.update({ where: { id: user.id }, - data: { isBetaUser: true }, + data: { isBetaUser: true, publicId }, }); } else { await db.user.update({ where: { id: user.id }, - data: { isBetaUser: true, isWaitlisted: true }, + data: { isBetaUser: true, isWaitlisted: true, publicId }, }); } }, diff --git a/apps/web/src/server/id.ts b/apps/web/src/server/id.ts new file mode 100644 index 00000000..ee9cd190 --- /dev/null +++ b/apps/web/src/server/id.ts @@ -0,0 +1,84 @@ +import { customAlphabet } from "nanoid"; + +const ID_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const ID_SUFFIX_LENGTH = 16; + +const nextIdSuffix = customAlphabet(ID_ALPHABET, ID_SUFFIX_LENGTH); + +function createId(prefix: string) { + return `${prefix}_${nextIdSuffix()}`; +} + +export function parseNumericId(input: string): number | null { + if (!/^\d+$/.test(input)) { + return null; + } + + const parsed = Number(input); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + return null; + } + + return parsed; +} + +export function createUserPublicId() { + return createId("usr"); +} + +export function createTeamPublicId() { + return createId("tm"); +} + +export function createDomainPublicId() { + return createId("dom"); +} + +export function createApiKeyPublicId() { + return createId("ak"); +} + +export function createSesSettingId() { + return createId("ses"); +} + +export function createTeamInviteId() { + return createId("inv"); +} + +export function createEmailId() { + return createId("em"); +} + +export function createEmailEventId() { + return createId("evt"); +} + +export function createContactBookId() { + return createId("cb"); +} + +export function createContactId() { + return createId("ct"); +} + +export function createCampaignId() { + return createId("cmp"); +} + +export function createTemplateId() { + return createId("tpl"); +} + +export function createSuppressionId() { + return createId("sup"); +} + +export function createWebhookId() { + return createId("wh"); +} + +export function createWebhookCallId() { + return createId("call"); +} diff --git a/apps/web/src/server/public-api/api/domains/delete-domain.ts b/apps/web/src/server/public-api/api/domains/delete-domain.ts index d0a5d862..4b25c73f 100644 --- a/apps/web/src/server/public-api/api/domains/delete-domain.ts +++ b/apps/web/src/server/public-api/api/domains/delete-domain.ts @@ -2,20 +2,26 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "../../hono"; import { db } from "~/server/db"; import { UnsendApiError } from "../../api-error"; -import { deleteDomain as deleteDomainService } from "~/server/service/domain-service"; +import { + deleteDomain as deleteDomainService, + resolveDomainId, +} from "~/server/service/domain-service"; const route = createRoute({ method: "delete", path: "/v1/domains/{id}", request: { params: z.object({ - id: z.coerce.number().openapi({ - param: { - name: "id", - in: "path", - }, - example: 1, - }), + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + example: "dom_3NfPq7hK9a2Tj6Rx", + }), }), }, responses: { @@ -57,7 +63,15 @@ const route = createRoute({ function deleteDomain(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; - const domainId = c.req.valid("param").id; + const identifier = c.req.valid("param").id; + const domainId = await resolveDomainId(identifier, team.id); + + if (!domainId) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } // Enforce API key domain restriction if (team.apiKey.domainId && team.apiKey.domainId !== domainId) { diff --git a/apps/web/src/server/public-api/api/domains/get-domain.ts b/apps/web/src/server/public-api/api/domains/get-domain.ts index 28572b05..c452b937 100644 --- a/apps/web/src/server/public-api/api/domains/get-domain.ts +++ b/apps/web/src/server/public-api/api/domains/get-domain.ts @@ -2,18 +2,23 @@ import { createRoute, z } from "@hono/zod-openapi"; import { DomainSchema } from "~/lib/zod/domain-schema"; import { PublicAPIApp } from "~/server/public-api/hono"; import { UnsendApiError } from "../../api-error"; -import { db } from "~/server/db"; -import { getDomain as getDomainService } from "~/server/service/domain-service"; +import { + getDomain as getDomainService, + resolveDomainId, +} from "~/server/service/domain-service"; const route = createRoute({ method: "get", path: "/v1/domains/{id}", request: { params: z.object({ - id: z.coerce.number().openapi({ - param: { name: "id", in: "path" }, - example: 1, - }), + id: z + .string() + .min(1) + .openapi({ + param: { name: "id", in: "path" }, + example: "dom_3NfPq7hK9a2Tj6Rx", + }), }), }, responses: { @@ -31,10 +36,18 @@ const route = createRoute({ function getDomain(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; - const id = c.req.valid("param").id; + const identifier = c.req.valid("param").id; + const domainId = await resolveDomainId(identifier, team.id); + + if (!domainId) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } // Enforce API key domain restriction (if any) - if (team.apiKey.domainId && team.apiKey.domainId !== id) { + if (team.apiKey.domainId && team.apiKey.domainId !== domainId) { throw new UnsendApiError({ code: "NOT_FOUND", message: "Domain not found", @@ -44,7 +57,7 @@ function getDomain(app: PublicAPIApp) { // Re-use service logic to enrich domain (verification status, DNS records, etc.) let enriched; try { - enriched = await getDomainService(id, team.id); + enriched = await getDomainService(domainId, team.id); } catch (e) { throw new UnsendApiError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/web/src/server/public-api/api/domains/verify-domain.ts b/apps/web/src/server/public-api/api/domains/verify-domain.ts index f2c52cc2..1390d809 100644 --- a/apps/web/src/server/public-api/api/domains/verify-domain.ts +++ b/apps/web/src/server/public-api/api/domains/verify-domain.ts @@ -1,19 +1,23 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { db } from "~/server/db"; +import { resolveDomainId } from "~/server/service/domain-service"; const route = createRoute({ method: "put", path: "/v1/domains/{id}/verify", request: { params: z.object({ - id: z.coerce.number().openapi({ - param: { - name: "id", - in: "path", - }, - example: 1, - }), + id: z + .string() + .min(1) + .openapi({ + param: { + name: "id", + in: "path", + }, + example: "dom_3NfPq7hK9a2Tj6Rx", + }), }), }, responses: { @@ -53,38 +57,46 @@ const route = createRoute({ function verifyDomain(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; - const domainId = c.req.valid("param").id; + const identifier = c.req.valid("param").id; + const domainId = await resolveDomainId(identifier, team.id); + + if (!domainId) { + return c.json({ error: "Domain not found" }, 404); + } // Check if API key has access to this domain let domain = null; - + if (team.apiKey.domainId) { // If API key is restricted to a specific domain, verify the requested domain matches if (domainId === team.apiKey.domainId) { domain = await db.domain.findFirst({ - where: { - teamId: team.id, - id: domainId + where: { + teamId: team.id, + id: domainId, }, }); } // If domainId doesn't match the API key's restriction, domain remains null } else { // API key has access to all team domains - domain = await db.domain.findFirst({ - where: { - teamId: team.id, - id: domainId - } + domain = await db.domain.findFirst({ + where: { + teamId: team.id, + id: domainId, + }, }); } if (!domain) { - return c.json({ - error: team.apiKey.domainId - ? "API key doesn't have access to this domain" - : "Domain not found" - }, 404); + return c.json( + { + error: team.apiKey.domainId + ? "API key doesn't have access to this domain" + : "Domain not found", + }, + 404, + ); } await db.domain.update({ diff --git a/apps/web/src/server/service/api-service.ts b/apps/web/src/server/service/api-service.ts index b489792f..b1e593f1 100644 --- a/apps/web/src/server/service/api-service.ts +++ b/apps/web/src/server/service/api-service.ts @@ -4,6 +4,7 @@ import { randomBytes } from "crypto"; import { smallNanoid } from "../nanoid"; import { createSecureHash, verifySecureHash } from "../crypto"; import { logger } from "../logger/log"; +import { createApiKeyPublicId } from "~/server/id"; export async function addApiKey({ name, @@ -20,13 +21,13 @@ export async function addApiKey({ // Validate domain ownership if domainId is provided if (domainId !== undefined) { const domain = await db.domain.findUnique({ - where: { + where: { id: domainId, - teamId: teamId + teamId: teamId, }, select: { id: true }, }); - + if (!domain) { throw new Error("DOMAIN_NOT_FOUND"); } @@ -40,6 +41,7 @@ export async function addApiKey({ await db.apiKey.create({ data: { + publicId: createApiKeyPublicId(), name, permission: permission, teamId, diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 5ce49bfe..ef36b459 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -23,6 +23,11 @@ import { validateApiKeyDomainAccess, validateDomainFromEmail, } from "./domain-service"; +import { + createCampaignId, + createEmailEventId, + createEmailId, +} from "~/server/id"; const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ "{{unsend_unsubscribe_url}}", @@ -42,7 +47,7 @@ function campaignHasUnsubscribePlaceholder( ...sources: Array ) { return CAMPAIGN_UNSUB_PLACEHOLDER_REGEXES.some((regex) => - sources.some((source) => (source ? regex.test(source) : false)) + sources.some((source) => (source ? regex.test(source) : false)), ); } @@ -70,7 +75,7 @@ function replaceContactVariables(html: string, contact: Contact) { } return fallback ?? ""; - } + }, ); } @@ -87,7 +92,7 @@ function sanitizeAddressList(addresses?: string | string[]) { } async function prepareCampaignHtml( - campaign: Campaign + campaign: Campaign, ): Promise<{ campaign: Campaign; html: string }> { if (campaign.content) { try { @@ -245,7 +250,7 @@ export async function createCampaignFromApi({ const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( sanitizedContent, - sanitizedHtml + sanitizedHtml, ); if (!unsubPlaceholderFound) { @@ -257,6 +262,7 @@ export async function createCampaignFromApi({ const campaign = await db.campaign.create({ data: { + id: createCampaignId(), name, from, subject, @@ -351,7 +357,7 @@ export async function sendCampaign(id: string) { const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( campaign.content, - html + html, ); if (!unsubPlaceholderFound) { @@ -430,7 +436,7 @@ export async function scheduleCampaign({ const unsubPlaceholderFound = campaignHasUnsubscribePlaceholder( campaign.content, - html + html, ); if (!unsubPlaceholderFound) { throw new UnsendApiError({ @@ -708,7 +714,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { const unsubscribeUrl = createUnsubUrl(contact.id, emailConfig.campaignId); const oneClickUnsubUrl = createOneClickUnsubUrl( contact.id, - emailConfig.campaignId + emailConfig.campaignId, ); // Check for suppressed emails before processing @@ -723,18 +729,18 @@ async function processContactEmail(jobData: CampaignEmailJob) { const suppressionResults = await SuppressionService.checkMultipleEmails( allEmailsToCheck, - emailConfig.teamId + emailConfig.teamId, ); // Filter each field separately const filteredToEmails = toEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredCcEmails = ccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredBccEmails = bccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); // Check if the contact's email (TO recipient) is suppressed @@ -754,11 +760,12 @@ async function processContactEmail(jobData: CampaignEmailJob) { campaignId: emailConfig.campaignId, teamId: emailConfig.teamId, }, - "Contact email is suppressed. Creating suppressed email record." + "Contact email is suppressed. Creating suppressed email record.", ); const email = await db.email.create({ data: { + id: createEmailId(), to: toEmails, replyTo: emailConfig.replyTo, cc: ccEmails.length > 0 ? ccEmails : undefined, @@ -777,6 +784,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "SUPPRESSED", data: { @@ -810,7 +818,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { campaignId: emailConfig.campaignId, teamId: emailConfig.teamId, }, - "Some CC recipients were suppressed and filtered out from campaign email." + "Some CC recipients were suppressed and filtered out from campaign email.", ); } @@ -822,13 +830,14 @@ async function processContactEmail(jobData: CampaignEmailJob) { campaignId: emailConfig.campaignId, teamId: emailConfig.teamId, }, - "Some BCC recipients were suppressed and filtered out from campaign email." + "Some BCC recipients were suppressed and filtered out from campaign email.", ); } // Create email with filtered recipients const email = await db.email.create({ data: { + id: createEmailId(), to: filteredToEmails, replyTo: emailConfig.replyTo, cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined, @@ -855,7 +864,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { } catch (error) { logger.error( { err: error }, - "Failed to create campaign email record so skipping email sending" + "Failed to create campaign email record so skipping email sending", ); return; } @@ -866,14 +875,14 @@ async function processContactEmail(jobData: CampaignEmailJob) { emailConfig.teamId, emailConfig.region, false, - oneClickUnsubUrl + oneClickUnsubUrl, ); } export async function updateCampaignAnalytics( campaignId: string, emailStatus: EmailStatus, - hardBounce: boolean = false + hardBounce: boolean = false, ) { const campaign = await db.campaign.findUnique({ where: { id: campaignId }, @@ -928,7 +937,7 @@ export class CampaignBatchService { CAMPAIGN_BATCH_QUEUE, { connection: getRedis(), - } + }, ); static worker = new Worker( @@ -1028,7 +1037,7 @@ export class CampaignBatchService { data: { lastCursor: newCursor, lastSentAt: new Date() }, }); }), - { connection: getRedis(), concurrency: 20 } + { connection: getRedis(), concurrency: 20 }, ); static async queueBatch({ @@ -1053,7 +1062,7 @@ export class CampaignBatchService { if (elapsedMs < windowMs) { logger.debug( { campaignId, remainingMs: windowMs - elapsedMs }, - "Defensive skip enqueue; window not elapsed" + "Defensive skip enqueue; window not elapsed", ); return; } @@ -1061,14 +1070,14 @@ export class CampaignBatchService { } catch (err) { logger.warn( { err, campaignId }, - "Failed defensive window check; proceeding to enqueue" + "Failed defensive window check; proceeding to enqueue", ); } await this.batchQueue.add( `campaign-${campaignId}`, { campaignId, teamId }, - { jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS } + { jobId: `campaign-batch:${campaignId}`, ...DEFAULT_QUEUE_OPTIONS }, ); } } diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts index 2e7043ed..bd94d7ef 100644 --- a/apps/web/src/server/service/contact-book-service.ts +++ b/apps/web/src/server/service/contact-book-service.ts @@ -2,6 +2,7 @@ import { CampaignStatus, type ContactBook } from "@prisma/client"; import { db } from "../db"; import { LimitService } from "./limit-service"; import { UnsendApiError } from "../public-api/api-error"; +import { createContactBookId } from "~/server/id"; export async function getContactBooks(teamId: number, search?: string) { return db.contactBook.findMany({ @@ -30,6 +31,7 @@ export async function createContactBook(teamId: number, name: string) { const created = await db.contactBook.create({ data: { + id: createContactBookId(), name, teamId, properties: {}, @@ -72,7 +74,7 @@ export async function updateContactBook( name?: string; properties?: Record; emoji?: string; - } + }, ) { return db.contactBook.update({ where: { id: contactBookId }, diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index f0772fe4..4e3c13d0 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -7,6 +7,7 @@ import { db } from "../db"; import { ContactQueueService } from "./contact-queue-service"; import { WebhookService } from "./webhook-service"; import { logger } from "../logger/log"; +import { createContactId } from "~/server/id"; export type ContactInput = { email: string; @@ -53,6 +54,7 @@ export async function addOrUpdateContact( }, }, create: { + id: createContactId(), contactBookId, email: contact.email, firstName: contact.firstName, diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 7b65fdb5..44a6d443 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -14,6 +14,7 @@ import { import { LimitService } from "./limit-service"; import type { DomainDnsRecord } from "~/types/domain"; import { WebhookService } from "./webhook-service"; +import { createDomainPublicId, parseNumericId } from "~/server/id"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); @@ -215,6 +216,7 @@ export async function createDomain( const domain = await db.domain.create({ data: { + publicId: createDomainPublicId(), name, publicKey, teamId, @@ -232,6 +234,37 @@ export async function createDomain( return withDnsRecords(domain); } +export async function findDomainByIdentifier( + identifier: string, + teamId: number, +) { + const numericId = parseNumericId(identifier); + + if (numericId !== null) { + return db.domain.findFirst({ + where: { + id: numericId, + teamId, + }, + }); + } + + return db.domain.findFirst({ + where: { + publicId: identifier, + teamId, + }, + }); +} + +export async function resolveDomainId( + identifier: string, + teamId: number, +): Promise { + const domain = await findDomainByIdentifier(identifier, teamId); + return domain?.id ?? null; +} + export async function getDomain(id: number, teamId: number) { let domain = await db.domain.findUnique({ where: { diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 730eedbb..9585eb1a 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -11,6 +11,7 @@ import { logger } from "../logger/log"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { LimitService } from "./limit-service"; import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; +import { createEmailEventId } from "~/server/id"; // Notifications about limits are handled inside LimitService. type QueueEmailJob = TeamJob<{ @@ -46,22 +47,22 @@ export class EmailQueueService { public static initializeQueue( region: string, quota: number, - transactionalQuotaPercentage: number + transactionalQuotaPercentage: number, ) { logger.info( { region }, - `[EmailQueueService]: Initializing queue for region` + `[EmailQueueService]: Initializing queue for region`, ); const transactionalQuota = Math.floor( - (quota * transactionalQuotaPercentage) / 100 + (quota * transactionalQuotaPercentage) / 100, ); const marketingQuota = quota - transactionalQuota; if (this.transactionalQueue.has(region)) { logger.info( { region, transactionalQuota }, - `[EmailQueueService]: Updating transactional quota for region` + `[EmailQueueService]: Updating transactional quota for region`, ); const transactionalWorker = this.transactionalWorker.get(region); if (transactionalWorker) { @@ -71,13 +72,13 @@ export class EmailQueueService { } else { logger.info( { region, transactionalQuota }, - `[EmailQueueService]: Creating transactional queue for region` + `[EmailQueueService]: Creating transactional queue for region`, ); const { queue: transactionalQueue, worker: transactionalWorker } = createQueueAndWorker( region, transactionalQuota !== 0 ? transactionalQuota : 1, - "transaction" + "transaction", ); this.transactionalQueue.set(region, transactionalQueue); this.transactionalWorker.set(region, transactionalWorker); @@ -86,7 +87,7 @@ export class EmailQueueService { if (this.marketingQueue.has(region)) { logger.info( { region, marketingQuota }, - `[EmailQueueService]: Updating marketing quota for region` + `[EmailQueueService]: Updating marketing quota for region`, ); const marketingWorker = this.marketingWorker.get(region); if (marketingWorker) { @@ -95,13 +96,13 @@ export class EmailQueueService { } else { logger.info( { region, marketingQuota }, - `[EmailQueueService]: Creating marketing queue for region` + `[EmailQueueService]: Creating marketing queue for region`, ); const { queue: marketingQueue, worker: marketingWorker } = createQueueAndWorker( region, marketingQuota !== 0 ? marketingQuota : 1, - "marketing" + "marketing", ); this.marketingQueue.set(region, marketingQueue); this.marketingWorker.set(region, marketingWorker); @@ -114,7 +115,7 @@ export class EmailQueueService { region: string, transactional: boolean, unsubUrl?: string, - delay?: number + delay?: number, ) { if (!this.initialized) { await this.init(); @@ -135,7 +136,7 @@ export class EmailQueueService { isBulk, teamId, }, - { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS } + { jobId: emailId, delay, ...DEFAULT_QUEUE_OPTIONS }, ); } @@ -155,7 +156,7 @@ export class EmailQueueService { unsubUrl?: string; delay?: number; timestamp?: number; // Optional: pass timestamp if needed for data - }[] + }[], ): Promise { if (jobs.length === 0) { logger.info("[EmailQueueService]: No jobs provided for bulk queue."); @@ -168,7 +169,7 @@ export class EmailQueueService { logger.info( { count: jobs.length }, - `[EmailQueueService]: Starting bulk queue for jobs.` + `[EmailQueueService]: Starting bulk queue for jobs.`, ); // Group jobs by region and type @@ -196,7 +197,7 @@ export class EmailQueueService { transactional: boolean; jobDetails: typeof jobs; } - > + >, ); const bulkAddPromises: Promise[] = []; @@ -206,7 +207,7 @@ export class EmailQueueService { if (!group || !group.queue) { logger.error( { groupKey, count: group?.jobDetails?.length ?? 0 }, - `[EmailQueueService]: Queue not found for group during bulk add. Skipping jobs.` + `[EmailQueueService]: Queue not found for group during bulk add. Skipping jobs.`, ); // Optionally: handle these skipped jobs (e.g., mark corresponding emails as failed) continue; @@ -232,22 +233,22 @@ export class EmailQueueService { logger.info( { count: bulkData.length, queue: queue.name }, - `[EmailQueueService]: Adding jobs to queue` + `[EmailQueueService]: Adding jobs to queue`, ); bulkAddPromises.push( queue.addBulk(bulkData).catch((error) => { logger.error( { err: error, queue: queue.name }, - `[EmailQueueService]: Failed to add bulk jobs to queue` + `[EmailQueueService]: Failed to add bulk jobs to queue`, ); // Optionally: handle bulk add failure (e.g., mark corresponding emails as failed) - }) + }), ); } await Promise.allSettled(bulkAddPromises); logger.info( - "[EmailQueueService]: Finished processing bulk queue requests." + "[EmailQueueService]: Finished processing bulk queue requests.", ); } @@ -255,7 +256,7 @@ export class EmailQueueService { emailId: string, region: string, transactional: boolean, - delay: number + delay: number, ) { if (!this.initialized) { await this.init(); @@ -277,7 +278,7 @@ export class EmailQueueService { public static async chancelEmail( emailId: string, region: string, - transactional: boolean + transactional: boolean, ) { if (!this.initialized) { await this.init(); @@ -302,7 +303,7 @@ export class EmailQueueService { this.initializeQueue( sesSetting.region, sesSetting.sesEmailRateLimit, - sesSetting.transactionalQuota + sesSetting.transactionalQuota, ); } this.initialized = true; @@ -312,7 +313,7 @@ export class EmailQueueService { async function executeEmail(job: QueueEmailJob) { logger.info( { emailId: job.data.emailId, elapsed: Date.now() - job.data.timestamp }, - `[EmailQueueService]: Executing email job` + `[EmailQueueService]: Executing email job`, ); const email = await db.email.findUnique({ @@ -328,7 +329,7 @@ async function executeEmail(job: QueueEmailJob) { if (!email) { logger.info( { emailId: job.data.emailId }, - `[EmailQueueService]: Email not found, skipping` + `[EmailQueueService]: Email not found, skipping`, ); return; } @@ -342,7 +343,7 @@ async function executeEmail(job: QueueEmailJob) { const configurationSetName = await getConfigurationSetName( domain?.clickTracking ?? false, domain?.openTracking ?? false, - domain?.region ?? env.AWS_DEFAULT_REGION + domain?.region ?? env.AWS_DEFAULT_REGION, ); if (!configurationSetName) { @@ -380,6 +381,7 @@ async function executeEmail(job: QueueEmailJob) { if (limitCheck.isLimitReached) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "FAILED", data: { @@ -421,7 +423,7 @@ async function executeEmail(job: QueueEmailJob) { logger.info( { emailId: email.id, sesEmailId: messageId }, - `[EmailQueueService]: Email sent` + `[EmailQueueService]: Email sent`, ); // Delete attachments and headers after sending the email @@ -432,6 +434,7 @@ async function executeEmail(job: QueueEmailJob) { } catch (error: any) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "FAILED", data: { diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index 58a36e19..d346fc4e 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -11,6 +11,7 @@ import { logger } from "../logger/log"; import { SuppressionService } from "./suppression-service"; import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; import { Prisma } from "@prisma/client"; +import { createEmailEventId, createEmailId } from "~/server/id"; async function checkIfValidEmail(emailId: string) { const email = await db.email.findUnique({ @@ -40,7 +41,7 @@ async function checkIfValidEmail(emailId: string) { export const replaceVariables = ( text: string, - variables: Record + variables: Record, ) => { return Object.keys(variables).reduce((accum, key) => { const re = new RegExp(`{{${key}}}`, "g"); @@ -53,7 +54,7 @@ export const replaceVariables = ( Send transactional email */ export async function sendEmail( - emailContent: EmailContent & { teamId: number; apiKeyId?: number } + emailContent: EmailContent & { teamId: number; apiKeyId?: number }, ) { const { to, @@ -110,18 +111,18 @@ export async function sendEmail( const suppressionResults = await SuppressionService.checkMultipleEmails( allEmailsToCheck, - teamId + teamId, ); // Filter each field separately const filteredToEmails = toEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredCcEmails = ccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredBccEmails = bccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); // Only block the email if all TO recipients are suppressed @@ -131,11 +132,12 @@ export async function sendEmail( to, teamId, }, - "All TO recipients are suppressed. No emails to send." + "All TO recipients are suppressed. No emails to send.", ); const email = await db.email.create({ data: { + id: createEmailId(), to: toEmails, from, subject: subject as string, @@ -153,6 +155,7 @@ export async function sendEmail( await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "SUPPRESSED", data: { @@ -173,7 +176,7 @@ export async function sendEmail( filteredCc: filteredCcEmails, teamId, }, - "Some CC recipients were suppressed and filtered out." + "Some CC recipients were suppressed and filtered out.", ); } @@ -184,7 +187,7 @@ export async function sendEmail( filteredBcc: filteredBccEmails, teamId, }, - "Some BCC recipients were suppressed and filtered out." + "Some BCC recipients were suppressed and filtered out.", ); } @@ -207,7 +210,7 @@ export async function sendEmail( acc[`{{${key}}}`] = variables?.[key] || ""; return acc; }, - {} as Record + {} as Record, ), }; @@ -248,6 +251,7 @@ export async function sendEmail( const email = await db.email.create({ data: { + id: createEmailId(), to: filteredToEmails, from, subject: subject as string, @@ -278,11 +282,12 @@ export async function sendEmail( domain.region, true, undefined, - delay + delay, ); } catch (error: any) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "FAILED", data: { @@ -307,7 +312,7 @@ export async function updateEmail( scheduledAt, }: { scheduledAt?: string; - } + }, ) { const { email, domain } = await checkIfValidEmail(emailId); @@ -354,6 +359,7 @@ export async function cancelEmail(emailId: string) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId, status: "CANCELLED", teamId: email.teamId, @@ -371,7 +377,7 @@ export async function sendBulkEmails( teamId: number; apiKeyId?: number; } - > + >, ) { if (emailContents.length === 0) { throw new UnsendApiError({ @@ -409,18 +415,18 @@ export async function sendBulkEmails( const suppressionResults = await SuppressionService.checkMultipleEmails( allEmailsToCheck, - content.teamId + content.teamId, ); // Filter each field separately const filteredToEmails = toEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredCcEmails = ccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); const filteredBccEmails = bccEmails.filter( - (email) => !suppressionResults[email] + (email) => !suppressionResults[email], ); // Only consider it suppressed if all TO recipients are suppressed @@ -437,13 +443,13 @@ export async function sendBulkEmails( suppressed: hasSuppressedToEmails, suppressedEmails: toEmails.filter((email) => suppressionResults[email]), suppressedCcEmails: ccEmails.filter( - (email) => suppressionResults[email] + (email) => suppressionResults[email], ), suppressedBccEmails: bccEmails.filter( - (email) => suppressionResults[email] + (email) => suppressionResults[email], ), }; - }) + }), ); const validEmails = emailChecks.filter((check) => !check.suppressed); @@ -460,7 +466,7 @@ export async function sendBulkEmails( suppressedAddresses: info.suppressedEmails, })), }, - "Filtered suppressed emails from bulk send" + "Filtered suppressed emails from bulk send", ); } @@ -517,7 +523,7 @@ export async function sendBulkEmails( acc[`{{${key}}}`] = variables?.[key] || ""; return acc; }, - {} as Record + {} as Record, ), }; @@ -544,6 +550,7 @@ export async function sendBulkEmails( const email = await db.email.create({ data: { + id: createEmailId(), to: originalToEmails, from, subject: subject as string, @@ -571,6 +578,7 @@ export async function sendBulkEmails( await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: "SUPPRESSED", data: { @@ -678,7 +686,7 @@ export async function sendBulkEmails( acc[`{{${key}}}`] = variables?.[key] || ""; return acc; }, - {} as Record + {} as Record, ), }; @@ -704,6 +712,7 @@ export async function sendBulkEmails( try { const email = await db.email.create({ data: { + id: createEmailId(), to: Array.isArray(to) ? to : [to], from, subject: subject as string, @@ -740,7 +749,7 @@ export async function sendBulkEmails( } catch (error: any) { logger.error( { err: error, to }, - `Failed to create email record for recipient` + `Failed to create email record for recipient`, ); // Continue processing other emails } @@ -763,6 +772,7 @@ export async function sendBulkEmails( createdEmails.map(async (email) => { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.email.id, status: "FAILED", data: { @@ -775,7 +785,7 @@ export async function sendBulkEmails( where: { id: email.email.id }, data: { latestStatus: "FAILED" }, }); - }) + }), ); throw error; } diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 275c99d7..04d21cfb 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -31,6 +31,7 @@ import { getChildLogger, logger, withLogger } from "../logger/log"; import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; import { WebhookService } from "./webhook-service"; +import { createEmailEventId } from "~/server/id"; export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -128,16 +129,19 @@ export async function parseSesHook(data: SesEvent) { // Get the actual affected recipients from the event data let recipientEmails: string[] = []; - + if (isHardBounced && data.bounce?.bouncedRecipients) { // For bounces, only add the recipients that actually bounced recipientEmails = data.bounce.bouncedRecipients.map( - (recipient) => recipient.emailAddress + (recipient) => recipient.emailAddress, ); - } else if (mailStatus === EmailStatus.COMPLAINED && data.complaint?.complainedRecipients) { + } else if ( + mailStatus === EmailStatus.COMPLAINED && + data.complaint?.complainedRecipients + ) { // For complaints, only add the recipients that actually complained recipientEmails = data.complaint.complainedRecipients.map( - (recipient) => recipient.emailAddress + (recipient) => recipient.emailAddress, ); } @@ -292,6 +296,7 @@ export async function parseSesHook(data: SesEvent) { await db.emailEvent.create({ data: { + id: createEmailEventId(), emailId: email.id, status: mailStatus, data: mailData as any, diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index 7402d713..57685184 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -7,6 +7,7 @@ import { EventType } from "@aws-sdk/client-sesv2"; import { EmailQueueService } from "./email-queue-service"; import { smallNanoid } from "../nanoid"; import { logger } from "../logger/log"; +import { createSesSettingId } from "~/server/id"; const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -25,7 +26,7 @@ export class SesSettingsService { private static initialized = false; public static async getSetting( - region = env.AWS_DEFAULT_REGION + region = env.AWS_DEFAULT_REGION, ): Promise { await this.checkInitialized(); @@ -75,7 +76,7 @@ export class SesSettingsService { if (!usesendUrlValidation.isValid) { throw new Error( - `Callback URL: ${usesendUrl} is not valid, status: ${usesendUrlValidation.code} message:${usesendUrlValidation.error}` + `Callback URL: ${usesendUrl} is not valid, status: ${usesendUrlValidation.code} message:${usesendUrlValidation.error}`, ); } @@ -93,6 +94,7 @@ export class SesSettingsService { const setting = await db.sesSetting.create({ data: { + id: createSesSettingId(), region, callbackUrl: `${parsedUrl}/api/ses_callback`, topic: topicName, @@ -110,7 +112,7 @@ export class SesSettingsService { await sns.subscribeEndpoint( topicArn!, `${setting.callbackUrl}`, - setting.region + setting.region, ); if (!setting) { @@ -122,14 +124,14 @@ export class SesSettingsService { EmailQueueService.initializeQueue( region, setting.sesEmailRateLimit, - setting.transactionalQuota + setting.transactionalQuota, ); logger.info( { transactionalQueue: EmailQueueService.transactionalQueue, marketingQueue: EmailQueueService.marketingQueue, }, - "Email queues initialized" + "Email queues initialized", ); await this.invalidateCache(); @@ -140,7 +142,7 @@ export class SesSettingsService { } catch (deleteError) { logger.error( { err: deleteError }, - "Failed to delete SNS topic after error" + "Failed to delete SNS topic after error", ); } } @@ -182,13 +184,13 @@ export class SesSettingsService { transactionalQueue: EmailQueueService.transactionalQueue, marketingQueue: EmailQueueService.marketingQueue, }, - "Email queues before update" + "Email queues before update", ); EmailQueueService.initializeQueue( setting.region, setting.sesEmailRateLimit, - setting.transactionalQuota + setting.transactionalQuota, ); logger.info( @@ -196,7 +198,7 @@ export class SesSettingsService { transactionalQueue: EmailQueueService.transactionalQueue, marketingQueue: EmailQueueService.marketingQueue, }, - "Email queues after update" + "Email queues after update", ); await this.invalidateCache(); @@ -240,7 +242,7 @@ async function registerConfigurationSet(setting: SesSetting) { configGeneral, setting.topicArn, GENERAL_EVENTS, - setting.region + setting.region, ); const configClick = `${setting.idPrefix}-${setting.region}-unsend-click`; @@ -248,7 +250,7 @@ async function registerConfigurationSet(setting: SesSetting) { configClick, setting.topicArn, [...GENERAL_EVENTS, "CLICK"], - setting.region + setting.region, ); const configOpen = `${setting.idPrefix}-${setting.region}-unsend-open`; @@ -256,7 +258,7 @@ async function registerConfigurationSet(setting: SesSetting) { configOpen, setting.topicArn, [...GENERAL_EVENTS, "OPEN"], - setting.region + setting.region, ); const configFull = `${setting.idPrefix}-${setting.region}-unsend-full`; @@ -264,7 +266,7 @@ async function registerConfigurationSet(setting: SesSetting) { configFull, setting.topicArn, [...GENERAL_EVENTS, "CLICK", "OPEN"], - setting.region + setting.region, ); return await db.sesSetting.update({ diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts index 5c7d1468..34a2c211 100644 --- a/apps/web/src/server/service/suppression-service.ts +++ b/apps/web/src/server/service/suppression-service.ts @@ -3,6 +3,7 @@ import { db } from "../db"; import { UnsendApiError } from "~/server/public-api/api-error"; import { logger } from "../logger/log"; import { deleteFromSesSuppressionList } from "../aws/ses"; +import { createSuppressionId } from "~/server/id"; export type AddSuppressionParams = { email: string; @@ -31,7 +32,7 @@ export class SuppressionService { * Add email to suppression list */ static async addSuppression( - params: AddSuppressionParams + params: AddSuppressionParams, ): Promise { const { email, teamId, reason, source } = params; @@ -44,6 +45,7 @@ export class SuppressionService { }, }, create: { + id: createSuppressionId(), email: email.toLowerCase().trim(), teamId, reason, @@ -64,7 +66,7 @@ export class SuppressionService { source, suppressionId: suppression.id, }, - "Email added to suppression list" + "Email added to suppression list", ); return suppression; @@ -77,7 +79,7 @@ export class SuppressionService { source, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to add email to suppression list" + "Failed to add email to suppression list", ); throw new UnsendApiError({ @@ -92,7 +94,7 @@ export class SuppressionService { */ static async isEmailSuppressed( email: string, - teamId: number + teamId: number, ): Promise { try { const suppression = await db.suppressionList.findUnique({ @@ -112,7 +114,7 @@ export class SuppressionService { teamId, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to check email suppression status" + "Failed to check email suppression status", ); // In case of error, err on the side of caution and don't suppress @@ -138,15 +140,15 @@ export class SuppressionService { if (uniqueRegions.length > 0) { const results = await Promise.allSettled( uniqueRegions.map((region) => - deleteFromSesSuppressionList(normalizedEmail, region) - ) + deleteFromSesSuppressionList(normalizedEmail, region), + ), ); // Check for failures - deleteFromSesSuppressionList returns false on error const failures = results.filter( (r) => r.status === "rejected" || - (r.status === "fulfilled" && r.value === false) + (r.status === "fulfilled" && r.value === false), ); if (failures.length > 0) { logger.warn( @@ -156,7 +158,7 @@ export class SuppressionService { failedRegions: failures.length, totalRegions: uniqueRegions.length, }, - "Some AWS SES regions failed during suppression removal" + "Some AWS SES regions failed during suppression removal", ); } } @@ -168,7 +170,7 @@ export class SuppressionService { teamId, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to cleanup AWS SES suppression (continuing with local deletion)" + "Failed to cleanup AWS SES suppression (continuing with local deletion)", ); } @@ -189,7 +191,7 @@ export class SuppressionService { teamId, suppressionId: deleted.id, }, - "Email removed from suppression list" + "Email removed from suppression list", ); } catch (error) { // If the record doesn't exist, that's fine - it's already not suppressed @@ -202,7 +204,7 @@ export class SuppressionService { email: normalizedEmail, teamId, }, - "Attempted to remove non-existent suppression - already not suppressed" + "Attempted to remove non-existent suppression - already not suppressed", ); return; } @@ -213,7 +215,7 @@ export class SuppressionService { teamId, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to remove email from suppression list" + "Failed to remove email from suppression list", ); throw new UnsendApiError({ @@ -227,7 +229,7 @@ export class SuppressionService { * Get suppression list for team with pagination */ static async getSuppressionList( - params: GetSuppressionListParams + params: GetSuppressionListParams, ): Promise { const { teamId, @@ -277,7 +279,7 @@ export class SuppressionService { reason, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to get suppression list" + "Failed to get suppression list", ); throw new UnsendApiError({ @@ -293,7 +295,7 @@ export class SuppressionService { static async addMultipleSuppressions( teamId: number, emails: string[], - reason: SuppressionReason + reason: SuppressionReason, ) { // Remove duplicates by normalizing emails first, then using Set const normalizedEmails = emails.map((email) => email.toLowerCase().trim()); @@ -313,11 +315,12 @@ export class SuppressionService { }); const emailsToAdd = batch.filter( - (email) => !alreadySuppressed.some((s) => s.email === email) + (email) => !alreadySuppressed.some((s) => s.email === email), ); await db.suppressionList.createMany({ data: emailsToAdd.map((email) => ({ + id: createSuppressionId(), teamId, email, reason, @@ -330,7 +333,7 @@ export class SuppressionService { originalCount: emails.length, uniqueCount: uniqueEmails.length, }, - "Added multiple emails to suppression list" + "Added multiple emails to suppression list", ); } catch (error) { logger.error( @@ -339,7 +342,7 @@ export class SuppressionService { uniqueCount: uniqueEmails.length, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to add multiple emails to suppression list" + "Failed to add multiple emails to suppression list", ); throw new UnsendApiError({ @@ -353,7 +356,7 @@ export class SuppressionService { * Get suppression statistics for a team */ static async getSuppressionStats( - teamId: number + teamId: number, ): Promise> { try { const stats = await db.suppressionList.groupBy({ @@ -379,7 +382,7 @@ export class SuppressionService { teamId, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to get suppression stats" + "Failed to get suppression stats", ); throw new UnsendApiError({ @@ -394,11 +397,11 @@ export class SuppressionService { */ static async checkMultipleEmails( emails: string[], - teamId: number + teamId: number, ): Promise> { try { const normalizedEmails = emails.map((email) => - email.toLowerCase().trim() + email.toLowerCase().trim(), ); const suppressions = await db.suppressionList.findMany({ @@ -428,7 +431,7 @@ export class SuppressionService { teamId, error: error instanceof Error ? error.message : "Unknown error", }, - "Failed to check multiple emails for suppression" + "Failed to check multiple emails for suppression", ); // In case of error, err on the side of caution and don't suppress any diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts index a4f97fb5..6737d368 100644 --- a/apps/web/src/server/service/team-service.ts +++ b/apps/web/src/server/service/team-service.ts @@ -10,6 +10,7 @@ import { LimitReason } from "~/lib/constants/plans"; import { LimitService } from "./limit-service"; import { renderUsageLimitReachedEmail } from "../email-templates/UsageLimitReachedEmail"; import { renderUsageWarningEmail } from "../email-templates/UsageWarningEmail"; +import { createTeamInviteId, createTeamPublicId } from "~/server/id"; // Cache stores exactly Prisma Team shape (no counts) @@ -83,6 +84,7 @@ export class TeamService { const created = await db.team.create({ data: { + publicId: createTeamPublicId(), name, teamUsers: { create: { @@ -188,6 +190,7 @@ export class TeamService { const teamInvite = await db.teamInvite.create({ data: { + id: createTeamInviteId(), teamId, email, role, diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts index 3888c7f3..5e65bd48 100644 --- a/apps/web/src/server/service/webhook-service.ts +++ b/apps/web/src/server/service/webhook-service.ts @@ -19,6 +19,7 @@ import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { logger } from "../logger/log"; import { LimitService } from "./limit-service"; import { UnsendApiError } from "../public-api/api-error"; +import { createWebhookCallId, createWebhookId } from "~/server/id"; const WEBHOOK_DISPATCH_CONCURRENCY = 25; const WEBHOOK_MAX_ATTEMPTS = 6; @@ -119,6 +120,7 @@ export class WebhookService { for (const webhook of activeWebhooks) { const call = await db.webhookCall.create({ data: { + id: createWebhookCallId(), webhookId: webhook.id, teamId: webhook.teamId, type: type, @@ -179,6 +181,7 @@ export class WebhookService { const call = await db.webhookCall.create({ data: { + id: createWebhookCallId(), webhookId: webhook.id, teamId: webhook.teamId, type: "webhook.test", @@ -242,6 +245,7 @@ export class WebhookService { return db.webhook.create({ data: { + id: createWebhookId(), teamId: params.teamId, url: params.url, description: params.description, diff --git a/apps/web/src/test/factories/core.ts b/apps/web/src/test/factories/core.ts index 801e745d..5854250f 100644 --- a/apps/web/src/test/factories/core.ts +++ b/apps/web/src/test/factories/core.ts @@ -1,5 +1,6 @@ import { Role, type Prisma, type Team, type User } from "@prisma/client"; import { db } from "~/server/db"; +import { createTeamPublicId, createUserPublicId } from "~/server/id"; let sequence = 1; @@ -13,6 +14,7 @@ export async function createUser(data?: Prisma.UserCreateInput): Promise { const n = nextValue(); return db.user.create({ data: { + publicId: createUserPublicId(), email: `user-${n}@example.com`, isBetaUser: true, isWaitlisted: false, @@ -25,6 +27,7 @@ export async function createTeam(data?: Prisma.TeamCreateInput): Promise { const n = nextValue(); return db.team.create({ data: { + publicId: createTeamPublicId(), name: `Team ${n}`, ...data, }, diff --git a/packages/sdk/src/domain.ts b/packages/sdk/src/domain.ts index fcbb6ab3..ee69fc88 100644 --- a/packages/sdk/src/domain.ts +++ b/packages/sdk/src/domain.ts @@ -63,7 +63,7 @@ export class Domains { return data; } - async verify(id: number): Promise { + async verify(id: string | number): Promise { const data = await this.usesend.put( `/domains/${id}/verify`, {}, @@ -71,7 +71,7 @@ export class Domains { return data; } - async get(id: number): Promise { + async get(id: string | number): Promise { const data = await this.usesend.get( `/domains/${id}`, ); @@ -79,7 +79,7 @@ export class Domains { return data; } - async delete(id: number): Promise { + async delete(id: string | number): Promise { const data = await this.usesend.delete( `/domains/${id}`, ); From 856ffaac6a1f7805d9bfdaf1c2a18af6d3623750 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Thu, 26 Feb 2026 04:37:11 +0000 Subject: [PATCH 2/2] refactor(web): unify ID generation with typed newId --- apps/web/src/server/api/routers/campaign.ts | 6 +- apps/web/src/server/api/routers/template.ts | 6 +- apps/web/src/server/auth.ts | 4 +- apps/web/src/server/id.ts | 87 ++++++------------- apps/web/src/server/service/api-service.ts | 4 +- .../src/server/service/campaign-service.ts | 14 ++- .../server/service/contact-book-service.ts | 4 +- .../web/src/server/service/contact-service.ts | 4 +- apps/web/src/server/service/domain-service.ts | 4 +- .../src/server/service/email-queue-service.ts | 6 +- apps/web/src/server/service/email-service.ts | 20 ++--- .../web/src/server/service/ses-hook-parser.ts | 4 +- .../server/service/ses-settings-service.ts | 4 +- .../src/server/service/suppression-service.ts | 6 +- apps/web/src/server/service/team-service.ts | 6 +- .../web/src/server/service/webhook-service.ts | 8 +- apps/web/src/test/factories/core.ts | 6 +- 17 files changed, 76 insertions(+), 117 deletions(-) diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 3b33c29a..b4fc5a90 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -17,7 +17,7 @@ import { getDocumentUploadUrl, isStorageConfigured, } from "~/server/service/storage-service"; -import { createCampaignId } from "~/server/id"; +import { newId } from "~/server/id"; const statuses = Object.values(CampaignStatus) as [CampaignStatus]; @@ -103,7 +103,7 @@ export const campaignRouter = createTRPCRouter({ const campaign = await db.campaign.create({ data: { - id: createCampaignId(), + id: newId("campaign"), ...input, teamId: team.id, domainId: domain.id, @@ -243,7 +243,7 @@ export const campaignRouter = createTRPCRouter({ async ({ ctx: { db, team, campaign } }) => { const newCampaign = await db.campaign.create({ data: { - id: createCampaignId(), + id: newId("campaign"), name: `${campaign.name} (Copy)`, from: campaign.from, replyTo: campaign.replyTo, diff --git a/apps/web/src/server/api/routers/template.ts b/apps/web/src/server/api/routers/template.ts index 3fa52f3a..f2d9a710 100644 --- a/apps/web/src/server/api/routers/template.ts +++ b/apps/web/src/server/api/routers/template.ts @@ -13,7 +13,7 @@ import { getDocumentUploadUrl, isStorageConfigured, } from "~/server/service/storage-service"; -import { createTemplateId } from "~/server/id"; +import { newId } from "~/server/id"; export const templateRouter = createTRPCRouter({ getTemplates: teamProcedure @@ -65,7 +65,7 @@ export const templateRouter = createTRPCRouter({ .mutation(async ({ ctx: { db, team }, input }) => { const template = await db.template.create({ data: { - id: createTemplateId(), + id: newId("template"), ...input, teamId: team.id, }, @@ -136,7 +136,7 @@ export const templateRouter = createTRPCRouter({ async ({ ctx: { db, team, template }, input }) => { const newTemplate = await db.template.create({ data: { - id: createTemplateId(), + id: newId("template"), name: `${template.name} (Copy)`, subject: template.subject, content: template.content, diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index ed2e4156..5adedbdb 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -13,7 +13,7 @@ import { Provider } from "next-auth/providers/index"; import { sendSignUpEmail } from "~/server/mailer"; import { env } from "~/env"; import { db } from "~/server/db"; -import { createUserPublicId } from "~/server/id"; +import { newId } from "~/server/id"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -121,7 +121,7 @@ export const authOptions: NextAuthOptions = { events: { createUser: async ({ user }) => { let invitesAvailable = false; - const publicId = createUserPublicId(); + const publicId = newId("userPublic"); if (user.email) { const invites = await db.teamInvite.findMany({ diff --git a/apps/web/src/server/id.ts b/apps/web/src/server/id.ts index ee9cd190..3f68895f 100644 --- a/apps/web/src/server/id.ts +++ b/apps/web/src/server/id.ts @@ -6,8 +6,31 @@ const ID_SUFFIX_LENGTH = 16; const nextIdSuffix = customAlphabet(ID_ALPHABET, ID_SUFFIX_LENGTH); -function createId(prefix: string) { - return `${prefix}_${nextIdSuffix()}`; +export const ID_PREFIX = { + userPublic: "usr", + teamPublic: "tm", + domainPublic: "dom", + apiKeyPublic: "ak", + sesSetting: "ses", + teamInvite: "inv", + email: "em", + emailEvent: "evt", + contactBook: "cb", + contact: "ct", + campaign: "cmp", + template: "tpl", + suppression: "sup", + webhook: "wh", + webhookCall: "call", +} as const; + +export type IdKind = keyof typeof ID_PREFIX; +type PrefixFor = (typeof ID_PREFIX)[K]; +export type IdFor = `${PrefixFor}_${string}`; + +export function newId(kind: K): IdFor { + const prefix = ID_PREFIX[kind]; + return `${prefix}_${nextIdSuffix()}` as IdFor; } export function parseNumericId(input: string): number | null { @@ -22,63 +45,3 @@ export function parseNumericId(input: string): number | null { return parsed; } - -export function createUserPublicId() { - return createId("usr"); -} - -export function createTeamPublicId() { - return createId("tm"); -} - -export function createDomainPublicId() { - return createId("dom"); -} - -export function createApiKeyPublicId() { - return createId("ak"); -} - -export function createSesSettingId() { - return createId("ses"); -} - -export function createTeamInviteId() { - return createId("inv"); -} - -export function createEmailId() { - return createId("em"); -} - -export function createEmailEventId() { - return createId("evt"); -} - -export function createContactBookId() { - return createId("cb"); -} - -export function createContactId() { - return createId("ct"); -} - -export function createCampaignId() { - return createId("cmp"); -} - -export function createTemplateId() { - return createId("tpl"); -} - -export function createSuppressionId() { - return createId("sup"); -} - -export function createWebhookId() { - return createId("wh"); -} - -export function createWebhookCallId() { - return createId("call"); -} diff --git a/apps/web/src/server/service/api-service.ts b/apps/web/src/server/service/api-service.ts index b1e593f1..7dce787c 100644 --- a/apps/web/src/server/service/api-service.ts +++ b/apps/web/src/server/service/api-service.ts @@ -4,7 +4,7 @@ import { randomBytes } from "crypto"; import { smallNanoid } from "../nanoid"; import { createSecureHash, verifySecureHash } from "../crypto"; import { logger } from "../logger/log"; -import { createApiKeyPublicId } from "~/server/id"; +import { newId } from "~/server/id"; export async function addApiKey({ name, @@ -41,7 +41,7 @@ export async function addApiKey({ await db.apiKey.create({ data: { - publicId: createApiKeyPublicId(), + publicId: newId("apiKeyPublic"), name, permission: permission, teamId, diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index ef36b459..3cf34912 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -23,11 +23,7 @@ import { validateApiKeyDomainAccess, validateDomainFromEmail, } from "./domain-service"; -import { - createCampaignId, - createEmailEventId, - createEmailId, -} from "~/server/id"; +import { newId } from "~/server/id"; const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ "{{unsend_unsubscribe_url}}", @@ -262,7 +258,7 @@ export async function createCampaignFromApi({ const campaign = await db.campaign.create({ data: { - id: createCampaignId(), + id: newId("campaign"), name, from, subject, @@ -765,7 +761,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: toEmails, replyTo: emailConfig.replyTo, cc: ccEmails.length > 0 ? ccEmails : undefined, @@ -784,7 +780,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "SUPPRESSED", data: { @@ -837,7 +833,7 @@ async function processContactEmail(jobData: CampaignEmailJob) { // Create email with filtered recipients const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: filteredToEmails, replyTo: emailConfig.replyTo, cc: filteredCcEmails.length > 0 ? filteredCcEmails : undefined, diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts index bd94d7ef..f35823d1 100644 --- a/apps/web/src/server/service/contact-book-service.ts +++ b/apps/web/src/server/service/contact-book-service.ts @@ -2,7 +2,7 @@ import { CampaignStatus, type ContactBook } from "@prisma/client"; import { db } from "../db"; import { LimitService } from "./limit-service"; import { UnsendApiError } from "../public-api/api-error"; -import { createContactBookId } from "~/server/id"; +import { newId } from "~/server/id"; export async function getContactBooks(teamId: number, search?: string) { return db.contactBook.findMany({ @@ -31,7 +31,7 @@ export async function createContactBook(teamId: number, name: string) { const created = await db.contactBook.create({ data: { - id: createContactBookId(), + id: newId("contactBook"), name, teamId, properties: {}, diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index 4e3c13d0..97896648 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -7,7 +7,7 @@ import { db } from "../db"; import { ContactQueueService } from "./contact-queue-service"; import { WebhookService } from "./webhook-service"; import { logger } from "../logger/log"; -import { createContactId } from "~/server/id"; +import { newId } from "~/server/id"; export type ContactInput = { email: string; @@ -54,7 +54,7 @@ export async function addOrUpdateContact( }, }, create: { - id: createContactId(), + id: newId("contact"), contactBookId, email: contact.email, firstName: contact.firstName, diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index 44a6d443..9e4ba7b5 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -14,7 +14,7 @@ import { import { LimitService } from "./limit-service"; import type { DomainDnsRecord } from "~/types/domain"; import { WebhookService } from "./webhook-service"; -import { createDomainPublicId, parseNumericId } from "~/server/id"; +import { newId, parseNumericId } from "~/server/id"; const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus)); @@ -216,7 +216,7 @@ export async function createDomain( const domain = await db.domain.create({ data: { - publicId: createDomainPublicId(), + publicId: newId("domainPublic"), name, publicKey, teamId, diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts index 9585eb1a..47a198d8 100644 --- a/apps/web/src/server/service/email-queue-service.ts +++ b/apps/web/src/server/service/email-queue-service.ts @@ -11,7 +11,7 @@ import { logger } from "../logger/log"; import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { LimitService } from "./limit-service"; import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; -import { createEmailEventId } from "~/server/id"; +import { newId } from "~/server/id"; // Notifications about limits are handled inside LimitService. type QueueEmailJob = TeamJob<{ @@ -381,7 +381,7 @@ async function executeEmail(job: QueueEmailJob) { if (limitCheck.isLimitReached) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "FAILED", data: { @@ -434,7 +434,7 @@ async function executeEmail(job: QueueEmailJob) { } catch (error: any) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "FAILED", data: { diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts index d346fc4e..d6c09fe3 100644 --- a/apps/web/src/server/service/email-service.ts +++ b/apps/web/src/server/service/email-service.ts @@ -11,7 +11,7 @@ import { logger } from "../logger/log"; import { SuppressionService } from "./suppression-service"; import { sanitizeCustomHeaders } from "~/server/utils/email-headers"; import { Prisma } from "@prisma/client"; -import { createEmailEventId, createEmailId } from "~/server/id"; +import { newId } from "~/server/id"; async function checkIfValidEmail(emailId: string) { const email = await db.email.findUnique({ @@ -137,7 +137,7 @@ export async function sendEmail( const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: toEmails, from, subject: subject as string, @@ -155,7 +155,7 @@ export async function sendEmail( await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "SUPPRESSED", data: { @@ -251,7 +251,7 @@ export async function sendEmail( const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: filteredToEmails, from, subject: subject as string, @@ -287,7 +287,7 @@ export async function sendEmail( } catch (error: any) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "FAILED", data: { @@ -359,7 +359,7 @@ export async function cancelEmail(emailId: string) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId, status: "CANCELLED", teamId: email.teamId, @@ -550,7 +550,7 @@ export async function sendBulkEmails( const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: originalToEmails, from, subject: subject as string, @@ -578,7 +578,7 @@ export async function sendBulkEmails( await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: "SUPPRESSED", data: { @@ -712,7 +712,7 @@ export async function sendBulkEmails( try { const email = await db.email.create({ data: { - id: createEmailId(), + id: newId("email"), to: Array.isArray(to) ? to : [to], from, subject: subject as string, @@ -772,7 +772,7 @@ export async function sendBulkEmails( createdEmails.map(async (email) => { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.email.id, status: "FAILED", data: { diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts index 04d21cfb..c6ecfc48 100644 --- a/apps/web/src/server/service/ses-hook-parser.ts +++ b/apps/web/src/server/service/ses-hook-parser.ts @@ -31,7 +31,7 @@ import { getChildLogger, logger, withLogger } from "../logger/log"; import { randomUUID } from "crypto"; import { SuppressionService } from "./suppression-service"; import { WebhookService } from "./webhook-service"; -import { createEmailEventId } from "~/server/id"; +import { newId } from "~/server/id"; export async function parseSesHook(data: SesEvent) { const mailStatus = getEmailStatus(data); @@ -296,7 +296,7 @@ export async function parseSesHook(data: SesEvent) { await db.emailEvent.create({ data: { - id: createEmailEventId(), + id: newId("emailEvent"), emailId: email.id, status: mailStatus, data: mailData as any, diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index 57685184..d39832cd 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -7,7 +7,7 @@ import { EventType } from "@aws-sdk/client-sesv2"; import { EmailQueueService } from "./email-queue-service"; import { smallNanoid } from "../nanoid"; import { logger } from "../logger/log"; -import { createSesSettingId } from "~/server/id"; +import { newId } from "~/server/id"; const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -94,7 +94,7 @@ export class SesSettingsService { const setting = await db.sesSetting.create({ data: { - id: createSesSettingId(), + id: newId("sesSetting"), region, callbackUrl: `${parsedUrl}/api/ses_callback`, topic: topicName, diff --git a/apps/web/src/server/service/suppression-service.ts b/apps/web/src/server/service/suppression-service.ts index 34a2c211..ddc6a704 100644 --- a/apps/web/src/server/service/suppression-service.ts +++ b/apps/web/src/server/service/suppression-service.ts @@ -3,7 +3,7 @@ import { db } from "../db"; import { UnsendApiError } from "~/server/public-api/api-error"; import { logger } from "../logger/log"; import { deleteFromSesSuppressionList } from "../aws/ses"; -import { createSuppressionId } from "~/server/id"; +import { newId } from "~/server/id"; export type AddSuppressionParams = { email: string; @@ -45,7 +45,7 @@ export class SuppressionService { }, }, create: { - id: createSuppressionId(), + id: newId("suppression"), email: email.toLowerCase().trim(), teamId, reason, @@ -320,7 +320,7 @@ export class SuppressionService { await db.suppressionList.createMany({ data: emailsToAdd.map((email) => ({ - id: createSuppressionId(), + id: newId("suppression"), teamId, email, reason, diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts index 6737d368..32bd4023 100644 --- a/apps/web/src/server/service/team-service.ts +++ b/apps/web/src/server/service/team-service.ts @@ -10,7 +10,7 @@ import { LimitReason } from "~/lib/constants/plans"; import { LimitService } from "./limit-service"; import { renderUsageLimitReachedEmail } from "../email-templates/UsageLimitReachedEmail"; import { renderUsageWarningEmail } from "../email-templates/UsageWarningEmail"; -import { createTeamInviteId, createTeamPublicId } from "~/server/id"; +import { newId } from "~/server/id"; // Cache stores exactly Prisma Team shape (no counts) @@ -84,7 +84,7 @@ export class TeamService { const created = await db.team.create({ data: { - publicId: createTeamPublicId(), + publicId: newId("teamPublic"), name, teamUsers: { create: { @@ -190,7 +190,7 @@ export class TeamService { const teamInvite = await db.teamInvite.create({ data: { - id: createTeamInviteId(), + id: newId("teamInvite"), teamId, email, role, diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts index 5e65bd48..5aa296cd 100644 --- a/apps/web/src/server/service/webhook-service.ts +++ b/apps/web/src/server/service/webhook-service.ts @@ -19,7 +19,7 @@ import { createWorkerHandler, TeamJob } from "../queue/bullmq-context"; import { logger } from "../logger/log"; import { LimitService } from "./limit-service"; import { UnsendApiError } from "../public-api/api-error"; -import { createWebhookCallId, createWebhookId } from "~/server/id"; +import { newId } from "~/server/id"; const WEBHOOK_DISPATCH_CONCURRENCY = 25; const WEBHOOK_MAX_ATTEMPTS = 6; @@ -120,7 +120,7 @@ export class WebhookService { for (const webhook of activeWebhooks) { const call = await db.webhookCall.create({ data: { - id: createWebhookCallId(), + id: newId("webhookCall"), webhookId: webhook.id, teamId: webhook.teamId, type: type, @@ -181,7 +181,7 @@ export class WebhookService { const call = await db.webhookCall.create({ data: { - id: createWebhookCallId(), + id: newId("webhookCall"), webhookId: webhook.id, teamId: webhook.teamId, type: "webhook.test", @@ -245,7 +245,7 @@ export class WebhookService { return db.webhook.create({ data: { - id: createWebhookId(), + id: newId("webhook"), teamId: params.teamId, url: params.url, description: params.description, diff --git a/apps/web/src/test/factories/core.ts b/apps/web/src/test/factories/core.ts index 5854250f..73fe7ad5 100644 --- a/apps/web/src/test/factories/core.ts +++ b/apps/web/src/test/factories/core.ts @@ -1,6 +1,6 @@ import { Role, type Prisma, type Team, type User } from "@prisma/client"; import { db } from "~/server/db"; -import { createTeamPublicId, createUserPublicId } from "~/server/id"; +import { newId } from "~/server/id"; let sequence = 1; @@ -14,7 +14,7 @@ export async function createUser(data?: Prisma.UserCreateInput): Promise { const n = nextValue(); return db.user.create({ data: { - publicId: createUserPublicId(), + publicId: newId("userPublic"), email: `user-${n}@example.com`, isBetaUser: true, isWaitlisted: false, @@ -27,7 +27,7 @@ export async function createTeam(data?: Prisma.TeamCreateInput): Promise { const n = nextValue(); return db.team.create({ data: { - publicId: createTeamPublicId(), + publicId: newId("teamPublic"), name: `Team ${n}`, ...data, },