From 3171ffcb6a714134ad3a281378a1c75e2cceb8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Mon, 30 Mar 2026 21:55:25 +0200 Subject: [PATCH 1/2] feat: added availability to create segment in contacts lists --- .../migration.sql | 29 ++ apps/web/prisma/schema.prisma | 21 +- .../campaigns/[campaignId]/edit/page.tsx | 84 +++- .../campaigns/[campaignId]/page.tsx | 10 + .../contacts/[contactBookId]/contact-list.tsx | 25 ++ .../contact-segments-manager.tsx | 424 ++++++++++++++++++ .../contacts/[contactBookId]/page.tsx | 6 + apps/web/src/lib/contact-segments.ts | 125 ++++++ .../web/src/lib/contact-segments.unit.test.ts | 77 ++++ apps/web/src/server/api/routers/campaign.ts | 90 +++- apps/web/src/server/api/routers/contacts.ts | 66 ++- .../src/server/service/campaign-service.ts | 80 ++-- .../server/service/contact-segment-filter.ts | 61 +++ .../server/service/contact-segment-service.ts | 183 ++++++++ .../service/webhook-service.unit.test.ts | 2 +- 15 files changed, 1224 insertions(+), 59 deletions(-) create mode 100644 apps/web/prisma/migrations/20260330193000_add_contact_segments/migration.sql create mode 100644 apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-segments-manager.tsx create mode 100644 apps/web/src/lib/contact-segments.ts create mode 100644 apps/web/src/lib/contact-segments.unit.test.ts create mode 100644 apps/web/src/server/service/contact-segment-filter.ts create mode 100644 apps/web/src/server/service/contact-segment-service.ts diff --git a/apps/web/prisma/migrations/20260330193000_add_contact_segments/migration.sql b/apps/web/prisma/migrations/20260330193000_add_contact_segments/migration.sql new file mode 100644 index 00000000..5f9443d0 --- /dev/null +++ b/apps/web/prisma/migrations/20260330193000_add_contact_segments/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "ContactSegment" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "contactBookId" TEXT NOT NULL, + "filters" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContactSegment_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "contactSegmentId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "ContactSegment_contactBookId_name_key" ON "ContactSegment"("contactBookId", "name"); + +-- CreateIndex +CREATE INDEX "ContactSegment_contactBookId_createdAt_idx" ON "ContactSegment"("contactBookId", "createdAt" DESC); + +-- CreateIndex +CREATE INDEX "Campaign_contactSegmentId_idx" ON "Campaign"("contactSegmentId"); + +-- AddForeignKey +ALTER TABLE "ContactSegment" ADD CONSTRAINT "ContactSegment_contactBookId_fkey" FOREIGN KEY ("contactBookId") REFERENCES "ContactBook"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Campaign" ADD CONSTRAINT "Campaign_contactSegmentId_fkey" FOREIGN KEY ("contactSegmentId") REFERENCES "ContactSegment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 1492adf8..1dd47517 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -307,6 +307,7 @@ model ContactBook { emoji String @default("📙") team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) contacts Contact[] + segments ContactSegment[] @@index([teamId]) } @@ -363,6 +364,7 @@ model Campaign { html String? content String? contactBookId String? + contactSegmentId String? scheduledAt DateTime? total Int @default(0) sent Int @default(0) @@ -382,12 +384,29 @@ model Campaign { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + contactSegment ContactSegment? @relation(fields: [contactSegmentId], references: [id], onDelete: SetNull) @@index([createdAt(sort: Desc)]) + @@index([contactSegmentId]) @@index([status, scheduledAt]) } +model ContactSegment { + id String @id @default(cuid()) + name String + contactBookId String + filters Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + contactBook ContactBook @relation(fields: [contactBookId], references: [id], onDelete: Cascade) + campaigns Campaign[] + + @@unique([contactBookId, name]) + @@index([contactBookId, createdAt(sort: Desc)]) +} + model Template { id String @id @default(cuid()) name String diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx index 2cef3002..75a35bfa 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -2,7 +2,6 @@ import { api } from "~/trpc/react"; import { Spinner } from "@usesend/ui/src/spinner"; -import { Button } from "@usesend/ui/src/button"; import { Input } from "@usesend/ui/src/input"; import { Editor } from "@usesend/email-editor"; import { use, useMemo, useState } from "react"; @@ -13,25 +12,7 @@ import { SelectItem, SelectTrigger, } from "@usesend/ui/src/select"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@usesend/ui/src/dialog"; import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@usesend/ui/src/form"; import { toast } from "@usesend/ui/src/toaster"; import { useDebouncedCallback } from "use-debounce"; import { formatDistanceToNow } from "date-fns"; @@ -41,6 +22,7 @@ import { AccordionItem, AccordionTrigger, } from "@usesend/ui/src/accordion"; +import { Badge } from "@usesend/ui/src/badge"; import ScheduleCampaign from "../../schedule-campaign"; import { useRouter } from "next/navigation"; @@ -109,6 +91,9 @@ function CampaignEditor({ const [subject, setSubject] = useState(campaign.subject); const [from, setFrom] = useState(campaign.from); const [contactBookId, setContactBookId] = useState(campaign.contactBookId); + const [contactSegmentId, setContactSegmentId] = useState( + campaign.contactSegmentId, + ); const [replyTo, setReplyTo] = useState( campaign.replyTo[0], ); @@ -119,6 +104,7 @@ function CampaignEditor({ const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ onSuccess: () => { utils.campaign.getCampaign.invalidate(); + utils.campaign.getCampaignAudience.invalidate({ campaignId: campaign.id }); setIsSaving(false); }, }); @@ -167,6 +153,13 @@ function CampaignEditor({ const contactBook = contactBooksQuery.data?.find( (book) => book.id === contactBookId, ); + const segmentsQuery = api.contacts.listSegments.useQuery( + { contactBookId: contactBookId ?? "" }, + { enabled: !!contactBookId }, + ); + const audienceQuery = api.campaign.getCampaignAudience.useQuery({ + campaignId: campaign.id, + }); const editorVariables = useMemo(() => { const baseVariables = ["email", "firstName", "lastName"]; const registryVariables = contactBook?.variables ?? []; @@ -401,14 +394,17 @@ function CampaignEditor({ { campaignId: campaign.id, contactBookId: val, + contactSegmentId: null, }, { onError: () => { setContactBookId(campaign.contactBookId); + setContactSegmentId(campaign.contactSegmentId); }, }, ); setContactBookId(val); + setContactSegmentId(null); }} > @@ -430,6 +426,56 @@ function CampaignEditor({ )} +
+ + + {audienceQuery.data ? ( + + {audienceQuery.data.count.toLocaleString()} recipients + + ) : null} +
diff --git a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx index a668f485..9f939ef0 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/page.tsx @@ -55,6 +55,9 @@ export default function CampaignDetailsPage({ refetchInterval: 5000, } ); + const { data: audience } = api.campaign.getCampaignAudience.useQuery({ + campaignId, + }); if (isLoading) { return ( @@ -260,6 +263,13 @@ export default function CampaignDetailsPage({
From
{campaign.from}
+
+
Audience
+
+ {campaign.contactSegment?.name ?? "All contacts"} + {audience ? ` (${audience.count.toLocaleString()})` : ""} +
+
Contact
{ + setSegmentId(value === "all" ? null : value); + setPage("1"); + }; + const exportQuery = api.contacts.exportContacts.useQuery( { contactBookId, search: search ?? undefined, + segmentId: segmentId ?? undefined, subscribed: status === "Subscribed" ? true @@ -201,6 +210,22 @@ export default function ContactList({ />
+ { + setEditorState((current) => ({ + ...current, + name: event.target.value, + })); + }} + placeholder="Paid users" + /> +
+ +
+
+
Conditions
+ +
+ + {editorState.conditions.map((condition, index) => ( +
+ + + + + {contactSegmentOperatorRequiresValue(condition.operator) ? ( + { + upsertCondition(condition.id, { + value: event.target.value, + }); + }} + placeholder="Value" + /> + ) : ( +
+ No value needed +
+ )} + + + +
+ Rule {index + 1}. All rules must match for a contact to be + included. +
+
+ ))} +
+ + + + + + + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx index b4e72da4..a5910f8d 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx @@ -44,6 +44,7 @@ import { } from "lucide-react"; import EditContactBook from "../edit-contact-book"; import DeleteContactBook from "../delete-contact-book"; +import ContactSegmentsManager from "./contact-segments-manager"; function ContactBookDetailActions({ contactBookId, @@ -474,6 +475,11 @@ export default function ContactsPage({ + +
diff --git a/apps/web/src/lib/contact-segments.ts b/apps/web/src/lib/contact-segments.ts new file mode 100644 index 00000000..e8819e3f --- /dev/null +++ b/apps/web/src/lib/contact-segments.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; +import { + getCanonicalContactVariableName, + getContactPropertyValue, +} from "~/lib/contact-properties"; + +export const contactSegmentOperatorSchema = z.enum([ + "equals", + "contains", + "isSet", + "isNotSet", +]); + +export type ContactSegmentOperator = z.infer< + typeof contactSegmentOperatorSchema +>; + +export const contactSegmentConditionSchema = z + .object({ + field: z.string().trim().min(1, "Field is required"), + operator: contactSegmentOperatorSchema, + value: z.string().trim().optional(), + }) + .superRefine((condition, ctx) => { + if ( + contactSegmentOperatorRequiresValue(condition.operator) && + !condition.value + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Value is required for this operator", + path: ["value"], + }); + } + }); + +export const contactSegmentDefinitionSchema = z.object({ + conditions: z + .array(contactSegmentConditionSchema) + .min(1, "Add at least one condition") + .max(20, "A segment can have at most 20 conditions"), +}); + +export type ContactSegmentCondition = z.infer< + typeof contactSegmentConditionSchema +>; +export type ContactSegmentDefinition = z.infer< + typeof contactSegmentDefinitionSchema +>; + +export function contactSegmentOperatorRequiresValue( + operator: ContactSegmentOperator, +) { + return operator === "equals" || operator === "contains"; +} + +export function normalizeContactSegmentDefinition( + definition: ContactSegmentDefinition, + allowedVariables: string[], +) { + return contactSegmentDefinitionSchema.parse({ + conditions: definition.conditions.map((condition) => { + const canonicalField = + getCanonicalContactVariableName(condition.field, allowedVariables) ?? + condition.field.trim(); + + return { + field: canonicalField, + operator: condition.operator, + ...(condition.value !== undefined + ? { value: condition.value.trim() } + : {}), + }; + }), + }); +} + +export function contactMatchesSegmentDefinition( + properties: Record | null | undefined, + definition: ContactSegmentDefinition, + allowedVariables: string[], +) { + const normalizedDefinition = normalizeContactSegmentDefinition( + definition, + allowedVariables, + ); + + return normalizedDefinition.conditions.every((condition) => { + const propertyValue = getContactPropertyValue( + properties, + condition.field, + allowedVariables, + ); + + switch (condition.operator) { + case "equals": + return propertyValue === condition.value; + case "contains": + return propertyValue?.includes(condition.value ?? "") ?? false; + case "isSet": + return Boolean(propertyValue && propertyValue.length > 0); + case "isNotSet": + return !propertyValue; + } + }); +} + +export function describeContactSegmentDefinition( + definition: ContactSegmentDefinition, +) { + return definition.conditions + .map((condition) => { + switch (condition.operator) { + case "equals": + return `${condition.field} is "${condition.value}"`; + case "contains": + return `${condition.field} contains "${condition.value}"`; + case "isSet": + return `${condition.field} is set`; + case "isNotSet": + return `${condition.field} is not set`; + } + }) + .join(" and "); +} diff --git a/apps/web/src/lib/contact-segments.unit.test.ts b/apps/web/src/lib/contact-segments.unit.test.ts new file mode 100644 index 00000000..f12fde1c --- /dev/null +++ b/apps/web/src/lib/contact-segments.unit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + contactMatchesSegmentDefinition, + describeContactSegmentDefinition, + normalizeContactSegmentDefinition, +} from "~/lib/contact-segments"; + +describe("contact-segments", () => { + it("normalizes condition fields to canonical variable names", () => { + expect( + normalizeContactSegmentDefinition( + { + conditions: [ + { + field: "Plan", + operator: "equals", + value: "paid", + }, + ], + }, + ["plan", "lifecycleStage"], + ), + ).toEqual({ + conditions: [ + { + field: "plan", + operator: "equals", + value: "paid", + }, + ], + }); + }); + + it("matches contacts against equals and contains conditions", () => { + expect( + contactMatchesSegmentDefinition( + { + plan: "paid", + lifecycleStage: "trial-ending", + }, + { + conditions: [ + { + field: "plan", + operator: "equals", + value: "paid", + }, + { + field: "lifecycleStage", + operator: "contains", + value: "trial", + }, + ], + }, + ["plan", "lifecycleStage"], + ), + ).toBe(true); + }); + + it("describes the segment definition for the UI", () => { + expect( + describeContactSegmentDefinition({ + conditions: [ + { + field: "plan", + operator: "equals", + value: "free", + }, + { + field: "company", + operator: "isSet", + }, + ], + }), + ).toBe('plan is "free" and company is set'); + }); +}); diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index a321ab0e..6d6d2f52 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -12,6 +12,7 @@ import { import { logger } from "~/server/logger/log"; import { nanoid } from "~/server/nanoid"; import * as campaignService from "~/server/service/campaign-service"; +import * as contactSegmentService from "~/server/service/contact-segment-service"; import { validateDomainFromEmail } from "~/server/service/domain-service"; import { getDocumentUploadUrl, @@ -121,14 +122,21 @@ export const campaignRouter = createTRPCRouter({ content: z.string().optional(), html: z.string().optional(), contactBookId: z.string().optional(), + contactSegmentId: z.string().nullable().optional(), replyTo: z.string().array().optional(), }), ) .mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => { const { html: htmlInput, campaignId, ...data } = input; - if (data.contactBookId) { + const nextContactBookId = data.contactBookId ?? campaignOld.contactBookId; + const nextContactSegmentId = + data.contactSegmentId !== undefined + ? data.contactSegmentId + : campaignOld.contactSegmentId; + + if (nextContactBookId) { const contactBook = await db.contactBook.findUnique({ - where: { id: data.contactBookId, teamId: team.id }, + where: { id: nextContactBookId, teamId: team.id }, }); if (!contactBook) { @@ -138,6 +146,21 @@ export const campaignRouter = createTRPCRouter({ }); } } + + if (nextContactSegmentId) { + if (!nextContactBookId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A segment requires a contact book", + }); + } + + await contactSegmentService.getSegmentForContactBook( + nextContactSegmentId, + nextContactBookId, + ); + } + let domainId = campaignOld.domainId; if (data.from) { const domain = await validateDomainFromEmail(data.from, team.id); @@ -190,18 +213,72 @@ export const campaignRouter = createTRPCRouter({ const imageUploadSupported = isStorageConfigured(); if (campaign?.contactBookId) { - const contactBook = await db.contactBook.findUnique({ - where: { id: campaign.contactBookId, teamId: team.id }, - }); - return { ...campaign, contactBook, imageUploadSupported }; + const [contactBook, contactSegment] = await Promise.all([ + db.contactBook.findUnique({ + where: { id: campaign.contactBookId, teamId: team.id }, + }), + campaign.contactSegmentId + ? contactSegmentService.getSegmentForContactBook( + campaign.contactSegmentId, + campaign.contactBookId, + ) + : Promise.resolve(null), + ]); + return { + ...campaign, + contactBook, + contactSegment, + imageUploadSupported, + }; } return { ...campaign, contactBook: null, + contactSegment: null, imageUploadSupported, }; }), + getCampaignAudience: campaignProcedure.query( + async ({ ctx: { db, team, campaign } }) => { + if (!campaign.contactBookId) { + return { count: 0, segment: null }; + } + + const contactBook = await db.contactBook.findUnique({ + where: { id: campaign.contactBookId, teamId: team.id }, + select: { variables: true }, + }); + if (!contactBook) { + return { count: 0, segment: null }; + } + + const segment = campaign.contactSegmentId + ? await contactSegmentService.getSegmentForContactBook( + campaign.contactSegmentId, + campaign.contactBookId, + ) + : null; + const segmentWhere = segment + ? await contactSegmentService.getSegmentWhereInput({ + contactBookId: campaign.contactBookId, + segmentId: segment.id, + variables: contactBook.variables, + }) + : undefined; + + const count = await db.contact.count({ + where: { + contactBookId: campaign.contactBookId, + subscribed: true, + ...(segmentWhere ?? {}), + }, + }); + + return { count, segment }; + }, + ), + latestEmails: campaignProcedure.query( async ({ ctx: { db, team, campaign } }) => { const emails = await db.email.findMany({ @@ -253,6 +330,7 @@ export const campaignRouter = createTRPCRouter({ teamId: team.id, domainId: campaign.domainId, contactBookId: campaign.contactBookId, + contactSegmentId: campaign.contactSegmentId, }, }); diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index afcb32f8..8fc9f2e7 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -9,6 +9,8 @@ import { } from "~/server/api/trpc"; import * as contactService from "~/server/service/contact-service"; import * as contactBookService from "~/server/service/contact-book-service"; +import * as contactSegmentService from "~/server/service/contact-segment-service"; +import { contactSegmentDefinitionSchema } from "~/lib/contact-segments"; export const contactsRouter = createTRPCRouter({ getContactBooks: teamProcedure @@ -74,15 +76,22 @@ export const contactsRouter = createTRPCRouter({ page: z.number().optional(), subscribed: z.boolean().optional(), search: z.string().optional(), + segmentId: z.string().optional(), }), ) - .query(async ({ ctx: { db }, input }) => { + .query(async ({ ctx: { db, contactBook }, input }) => { const page = input.page || 1; const limit = 30; const offset = (page - 1) * limit; + const segmentWhere = await contactSegmentService.getSegmentWhereInput({ + contactBookId: input.contactBookId, + segmentId: input.segmentId, + variables: contactBook.variables, + }); const whereConditions: Prisma.ContactFindManyArgs["where"] = { contactBookId: input.contactBookId, + ...(segmentWhere ?? {}), ...(input.subscribed !== undefined ? { subscribed: input.subscribed } : {}), @@ -254,11 +263,19 @@ export const contactsRouter = createTRPCRouter({ z.object({ subscribed: z.boolean().optional(), search: z.string().optional(), + segmentId: z.string().optional(), }), ) - .query(async ({ ctx: { db }, input }) => { + .query(async ({ ctx: { db, contactBook }, input }) => { + const segmentWhere = await contactSegmentService.getSegmentWhereInput({ + contactBookId: input.contactBookId, + segmentId: input.segmentId, + variables: contactBook.variables, + }); + const whereConditions: Prisma.ContactFindManyArgs["where"] = { contactBookId: input.contactBookId, + ...(segmentWhere ?? {}), ...(input.subscribed !== undefined ? { subscribed: input.subscribed } : {}), @@ -292,4 +309,49 @@ export const contactsRouter = createTRPCRouter({ return contacts; }), + + listSegments: contactBookProcedure.query(async ({ ctx: { contactBook } }) => { + return contactSegmentService.listSegments(contactBook.id); + }), + + createSegment: contactBookProcedure + .input( + z.object({ + name: z.string().trim().min(1), + definition: contactSegmentDefinitionSchema, + }), + ) + .mutation(async ({ ctx: { contactBook }, input }) => { + return contactSegmentService.createSegment({ + contactBookId: contactBook.id, + name: input.name, + definition: input.definition, + }); + }), + + updateSegment: contactBookProcedure + .input( + z.object({ + segmentId: z.string(), + name: z.string().trim().min(1), + definition: contactSegmentDefinitionSchema, + }), + ) + .mutation(async ({ ctx: { contactBook }, input }) => { + return contactSegmentService.updateSegment({ + segmentId: input.segmentId, + contactBookId: contactBook.id, + name: input.name, + definition: input.definition, + }); + }), + + deleteSegment: contactBookProcedure + .input(z.object({ segmentId: z.string() })) + .mutation(async ({ ctx: { contactBook }, input }) => { + return contactSegmentService.deleteSegment( + input.segmentId, + contactBook.id, + ); + }), }); diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 9ae6519b..832ce275 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -7,6 +7,7 @@ import { Campaign, Contact, EmailStatus, + Prisma, UnsubscribeReason, } from "@prisma/client"; import { EmailQueueService } from "./email-queue-service"; @@ -24,6 +25,7 @@ import { validateApiKeyDomainAccess, validateDomainFromEmail, } from "./domain-service"; +import { getSegmentWhereInput } from "./contact-segment-service"; const CAMPAIGN_UNSUB_PLACEHOLDER_TOKENS = [ "{{unsend_unsubscribe_url}}", @@ -205,6 +207,43 @@ async function prepareCampaignHtml( throw new Error("No content added for campaign"); } +async function getCampaignAudienceContext(campaign: Campaign) { + if (!campaign.contactBookId) { + throw new Error("No contact book found for campaign"); + } + + const contactBook = await db.contactBook.findUnique({ + where: { id: campaign.contactBookId }, + select: { variables: true }, + }); + + if (!contactBook) { + throw new Error("Contact book not found"); + } + + const segmentWhere = await getSegmentWhereInput({ + contactBookId: campaign.contactBookId, + segmentId: campaign.contactSegmentId ?? undefined, + variables: contactBook.variables, + }); + + const contactWhere: Prisma.ContactWhereInput = { + contactBookId: campaign.contactBookId, + subscribed: true, + ...(segmentWhere ?? {}), + }; + + const allowedVariables = [ + ...BUILT_IN_CONTACT_VARIABLES, + ...contactBook.variables, + ]; + + return { + allowedVariables, + contactWhere, + }; +} + async function renderCampaignHtmlForContact({ campaign, contact, @@ -450,10 +489,6 @@ export async function sendCampaign(id: string) { campaign = prepared.campaign; const html = prepared.html; - if (!campaign.contactBookId) { - throw new Error("No contact book found for campaign"); - } - if (!html) { throw new Error("No HTML content for campaign"); } @@ -467,9 +502,10 @@ export async function sendCampaign(id: string) { throw new Error("Campaign must include an unsubscribe link before sending"); } - // Count subscribed contacts for total, don't load all into memory + const { contactWhere } = await getCampaignAudienceContext(campaign); + const total = await db.contact.count({ - where: { contactBookId: campaign.contactBookId, subscribed: true }, + where: contactWhere, }); // Mark as scheduled (or keep running if already running), set totals and scheduledAt if not set @@ -523,13 +559,6 @@ export async function scheduleCampaign({ }); } - if (!campaign.contactBookId) { - throw new UnsendApiError({ - code: "BAD_REQUEST", - message: "No contact book found for campaign", - }); - } - if (!html) { throw new UnsendApiError({ code: "BAD_REQUEST", @@ -548,9 +577,10 @@ export async function scheduleCampaign({ }); } - // Count subscribed contacts for total + const { contactWhere } = await getCampaignAudienceContext(campaign); + const total = await db.contact.count({ - where: { contactBookId: campaign.contactBookId, subscribed: true }, + where: contactWhere, }); if (total === 0) { @@ -1082,11 +1112,8 @@ export class CampaignBatchService { } const batchSize = campaign.batchSize ?? 500; - - const where = { - contactBookId: campaign.contactBookId, - subscribed: true, - } as const; + const { allowedVariables, contactWhere } = + await getCampaignAudienceContext(campaign); const pagination: any = { take: batchSize, orderBy: { id: "asc" as const }, @@ -1096,18 +1123,11 @@ export class CampaignBatchService { pagination.skip = 1; // do not include the cursor row } - const contacts = await db.contact.findMany({ where, ...pagination }); - - const contactBook = await db.contactBook.findUnique({ - where: { id: campaign.contactBookId }, - select: { variables: true }, + const contacts = await db.contact.findMany({ + where: contactWhere, + ...pagination, }); - const allowedVariables = [ - ...BUILT_IN_CONTACT_VARIABLES, - ...(contactBook?.variables ?? []), - ]; - if (contacts.length === 0) { // No more contacts -> mark SENT await db.campaign.update({ diff --git a/apps/web/src/server/service/contact-segment-filter.ts b/apps/web/src/server/service/contact-segment-filter.ts new file mode 100644 index 00000000..7efdb10f --- /dev/null +++ b/apps/web/src/server/service/contact-segment-filter.ts @@ -0,0 +1,61 @@ +import { Prisma } from "@prisma/client"; +import { + normalizeContactSegmentDefinition, + type ContactSegmentCondition, + type ContactSegmentDefinition, +} from "~/lib/contact-segments"; + +export function buildContactSegmentWhere( + definition: ContactSegmentDefinition, + allowedVariables: string[], +): Prisma.ContactWhereInput { + const normalizedDefinition = normalizeContactSegmentDefinition( + definition, + allowedVariables, + ); + + return { + AND: normalizedDefinition.conditions.map((condition) => + buildContactSegmentConditionWhere(condition), + ), + }; +} + +function buildContactSegmentConditionWhere( + condition: ContactSegmentCondition, +): Prisma.ContactWhereInput { + const path = [condition.field]; + + switch (condition.operator) { + case "equals": + return { + properties: { + path, + equals: condition.value, + }, + }; + case "contains": + return { + properties: { + path, + string_contains: condition.value, + }, + }; + case "isSet": + return { + NOT: { + properties: { + path, + equals: Prisma.JsonNull, + }, + }, + }; + case "isNotSet": + return { + properties: { + path, + equals: Prisma.JsonNull, + }, + }; + } +} diff --git a/apps/web/src/server/service/contact-segment-service.ts b/apps/web/src/server/service/contact-segment-service.ts new file mode 100644 index 00000000..73d14dbd --- /dev/null +++ b/apps/web/src/server/service/contact-segment-service.ts @@ -0,0 +1,183 @@ +import { Prisma } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; +import { + contactSegmentDefinitionSchema, + normalizeContactSegmentDefinition, + type ContactSegmentDefinition, +} from "~/lib/contact-segments"; +import { db } from "~/server/db"; +import { buildContactSegmentWhere } from "./contact-segment-filter"; + +export async function listSegments(contactBookId: string) { + const contactBook = await db.contactBook.findUnique({ + where: { id: contactBookId }, + select: { + variables: true, + segments: { + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + + if (!contactBook) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Contact book not found", + }); + } + + const segments = await Promise.all( + contactBook.segments.map(async (segment) => { + const definition = contactSegmentDefinitionSchema.parse(segment.filters); + const count = await db.contact.count({ + where: { + contactBookId, + ...buildContactSegmentWhere(definition, contactBook.variables), + }, + }); + + return { + ...segment, + filters: definition, + count, + }; + }), + ); + + return segments; +} + +export async function getSegmentForContactBook( + segmentId: string, + contactBookId: string, +) { + const segment = await db.contactSegment.findFirst({ + where: { + id: segmentId, + contactBookId, + }, + }); + + if (!segment) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Segment not found", + }); + } + + return { + ...segment, + filters: contactSegmentDefinitionSchema.parse(segment.filters), + }; +} + +export async function createSegment({ + contactBookId, + name, + definition, +}: { + contactBookId: string; + name: string; + definition: ContactSegmentDefinition; +}) { + const contactBook = await db.contactBook.findUnique({ + where: { id: contactBookId }, + select: { variables: true }, + }); + + if (!contactBook) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Contact book not found", + }); + } + + const filters = normalizeContactSegmentDefinition( + definition, + contactBook.variables, + ); + + return db.contactSegment.create({ + data: { + contactBookId, + name: name.trim(), + filters: filters as Prisma.InputJsonObject, + }, + }); +} + +export async function updateSegment({ + segmentId, + contactBookId, + name, + definition, +}: { + segmentId: string; + contactBookId: string; + name: string; + definition: ContactSegmentDefinition; +}) { + const contactBook = await db.contactBook.findUnique({ + where: { id: contactBookId }, + select: { variables: true }, + }); + + if (!contactBook) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Contact book not found", + }); + } + + await getSegmentForContactBook(segmentId, contactBookId); + + const filters = normalizeContactSegmentDefinition( + definition, + contactBook.variables, + ); + + return db.contactSegment.update({ + where: { id: segmentId }, + data: { + name: name.trim(), + filters: filters as Prisma.InputJsonObject, + }, + }); +} + +export async function deleteSegment(segmentId: string, contactBookId: string) { + await getSegmentForContactBook(segmentId, contactBookId); + + return db.contactSegment.delete({ + where: { id: segmentId }, + }); +} + +export async function getSegmentWhereInput({ + contactBookId, + segmentId, + variables, +}: { + contactBookId: string; + segmentId?: string; + variables?: string[]; +}) { + if (!segmentId) { + return undefined; + } + + const segment = await getSegmentForContactBook(segmentId, contactBookId); + const allowedVariables = + variables ?? + ( + await db.contactBook.findUnique({ + where: { id: contactBookId }, + select: { variables: true }, + }) + )?.variables ?? + []; + + return buildContactSegmentWhere(segment.filters, allowedVariables); +} diff --git a/apps/web/src/server/service/webhook-service.unit.test.ts b/apps/web/src/server/service/webhook-service.unit.test.ts index 73fd9feb..a9fa335b 100644 --- a/apps/web/src/server/service/webhook-service.unit.test.ts +++ b/apps/web/src/server/service/webhook-service.unit.test.ts @@ -572,7 +572,7 @@ describe("WebhookService.emit domain filters", () => { await WebhookService.emit(10, "contact.created", { id: "contact_1", email: "test@example.com", - contactBookId: 1, + contactBookId: "book_1", subscribed: true, properties: {}, firstName: null, From c02baadab6a4edd4fbadb805a8d9ddfe81baeeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Mon, 30 Mar 2026 23:06:16 +0200 Subject: [PATCH 2/2] improvement: fixed potentials bugs and improved functionning --- .../contacts/[contactBookId]/contact-list.tsx | 15 ++++ .../contact-segments-manager.tsx | 90 +++++++++++++------ .../server/service/contact-segment-filter.ts | 4 +- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx index f6b4479f..7c6683a9 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx @@ -35,6 +35,7 @@ import { } from "@usesend/ui/src/tooltip"; import { UnsubscribeReason } from "@prisma/client"; import { Download } from "lucide-react"; +import { useEffect } from "react"; function sanitizeFilename( name: string | undefined, @@ -88,6 +89,20 @@ export default function ContactList({ const pageNumber = Number(page); const segmentsQuery = api.contacts.listSegments.useQuery({ contactBookId }); + useEffect(() => { + if (!segmentId || !segmentsQuery.data) { + return; + } + + const segmentExists = segmentsQuery.data.some( + (segment) => segment.id === segmentId, + ); + + if (!segmentExists) { + setSegmentId(null); + } + }, [segmentId, segmentsQuery.data, setSegmentId]); + const contactsQuery = api.contacts.contacts.useQuery({ contactBookId, page: pageNumber, diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-segments-manager.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-segments-manager.tsx index 9deb77e5..80fe5d47 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-segments-manager.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-segments-manager.tsx @@ -11,6 +11,7 @@ import { DialogTitle, } from "@usesend/ui/src/dialog"; import { Input } from "@usesend/ui/src/input"; +import { Label } from "@usesend/ui/src/label"; import { Select, SelectContent, @@ -122,6 +123,13 @@ export default function ContactSegmentsManager({ }); const saveSegment = () => { + if (contactBookVariables.length === 0 && !editorState.id) { + toast.error( + "Add custom variables on this contact book before creating segments.", + ); + return; + } + const definition: ContactSegmentDefinition = { conditions: editorState.conditions.map(({ field, operator, value }) => ({ field, @@ -165,22 +173,6 @@ export default function ContactSegmentsManager({ })); }; - if (contactBookVariables.length === 0) { - return ( - - - - - Segments - - - - Add custom variables on this contact book before creating segments. - - - ); - } - return ( <> @@ -191,6 +183,7 @@ export default function ContactSegmentsManager({ + {contactBookVariables.length === 0 ? ( +
+ Add custom variables on this contact book before creating new + segments. +
+ ) : null} {segmentsQuery.isLoading ? (
@@ -228,6 +227,7 @@ export default function ContactSegmentsManager({