From 59ff5e3fc68a1435635fea9c573f2a1bc0d288f6 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Tue, 24 Feb 2026 20:39:33 +0000 Subject: [PATCH 1/7] feat: add contact-book variable registry for campaign personalization --- .../migration.sql | 2 + apps/web/prisma/schema.prisma | 1 + .../campaigns/[campaignId]/edit/page.tsx | 36 +- .../[contactBookId]/bulk-upload-contacts.tsx | 110 +++++- .../contacts/[contactBookId]/contact-list.tsx | 15 +- .../contacts/[contactBookId]/edit-contact.tsx | 52 ++- .../contacts/[contactBookId]/page.tsx | 18 + .../(dashboard)/contacts/add-contact-book.tsx | 28 ++ .../contacts/edit-contact-book.tsx | 34 +- apps/web/src/lib/zod/contact-book-schema.ts | 4 + apps/web/src/server/api/routers/contacts.ts | 354 +++++++++--------- .../api/contacts/create-contact-book.ts | 11 +- .../api/contacts/get-contact-book.ts | 139 +++---- .../api/contacts/get-contact-books.ts | 45 +-- .../api/contacts/update-contact-book.ts | 2 + .../src/server/service/campaign-service.ts | 173 +++++++-- .../server/service/contact-book-service.ts | 56 ++- .../service/contact-variable-service.ts | 33 ++ 18 files changed, 767 insertions(+), 346 deletions(-) create mode 100644 apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql create mode 100644 apps/web/src/server/service/contact-variable-service.ts diff --git a/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql b/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql new file mode 100644 index 00000000..5424b353 --- /dev/null +++ b/apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ContactBook" ADD COLUMN "variables" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index da4479f0..58e26f68 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -296,6 +296,7 @@ model ContactBook { id String @id @default(cuid()) name String teamId Int + variables String[] @default([]) properties Json doubleOptInEnabled Boolean @default(false) doubleOptInFrom 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 e445731c..4cbb1c2e 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -5,7 +5,7 @@ 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, useState } from "react"; +import { use, useMemo, useState } from "react"; import { Campaign } from "@prisma/client"; import { Select, @@ -65,7 +65,7 @@ export default function EditCampaignPage({ { campaignId }, { enabled: !!campaignId, - } + }, ); if (isLoading) { @@ -102,7 +102,7 @@ function CampaignEditor({ const utils = api.useUtils(); const [json, setJson] = useState | undefined>( - campaign.content ? JSON.parse(campaign.content) : undefined + campaign.content ? JSON.parse(campaign.content) : undefined, ); const [isSaving, setIsSaving] = useState(false); const [name, setName] = useState(campaign.name); @@ -110,10 +110,10 @@ function CampaignEditor({ const [from, setFrom] = useState(campaign.from); const [contactBookId, setContactBookId] = useState(campaign.contactBookId); const [replyTo, setReplyTo] = useState( - campaign.replyTo[0] + campaign.replyTo[0], ); const [previewText, setPreviewText] = useState( - campaign.previewText + campaign.previewText, ); const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ @@ -136,13 +136,13 @@ function CampaignEditor({ const deboucedUpdateCampaign = useDebouncedCallback( updateEditorContent, - 1000 + 1000, ); const handleFileChange = async (file: File) => { if (file.size > IMAGE_SIZE_LIMIT) { throw new Error( - `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB` + `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`, ); } @@ -165,8 +165,14 @@ function CampaignEditor({ }; const contactBook = contactBooksQuery.data?.find( - (book) => book.id === contactBookId + (book) => book.id === contactBookId, ); + const editorVariables = useMemo(() => { + const baseVariables = ["email", "firstName", "lastName"]; + const registryVariables = contactBook?.variables ?? []; + + return Array.from(new Set([...baseVariables, ...registryVariables])); + }, [contactBook?.variables]); return (
@@ -196,7 +202,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setName(campaign.name); }, - } + }, ); }} /> @@ -251,7 +257,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setSubject(campaign.subject); }, - } + }, ); }} className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent" @@ -291,7 +297,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setFrom(campaign.from); }, - } + }, ); }} disabled={isApiCampaign} @@ -327,7 +333,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setReplyTo(campaign.replyTo[0]); }, - } + }, ); }} disabled={isApiCampaign} @@ -365,7 +371,7 @@ function CampaignEditor({ toast.error(`${e.message}. Reverting changes.`); setPreviewText(campaign.previewText ?? ""); }, - } + }, ); }} className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border" @@ -397,7 +403,7 @@ function CampaignEditor({ onError: () => { setContactBookId(campaign.contactBookId); }, - } + }, ); setContactBookId(val); }} @@ -441,7 +447,7 @@ function CampaignEditor({ setIsSaving(true); deboucedUpdateCampaign(); }} - variables={["email", "firstName", "lastName"]} + variables={editorVariables} uploadImage={ campaign.imageUploadSupported ? handleFileChange : undefined } diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx index e512763e..0a59223c 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx @@ -34,6 +34,7 @@ interface ParsedContact { email: string; firstName?: string; lastName?: string; + properties?: Record; subscribed?: boolean; isValid: boolean; } @@ -77,11 +78,12 @@ export default function BulkUploadContacts({ const parseContactLine = ( line: string, - isFirstLine: boolean = false, + headers?: string[], ): { email: string; firstName?: string; lastName?: string; + properties?: Record; subscribed?: boolean; } | null => { const trimmedLine = line.trim(); @@ -107,27 +109,89 @@ export default function BulkUploadContacts({ if (parts.length === 0 || !parts[0]) return null; - // Check if this is a header row (case-insensitive) - if (isFirstLine) { - const firstPart = parts[0]?.toLowerCase(); - if ( - firstPart === "email" || - firstPart === "e-mail" || - firstPart === "email address" - ) { - return null; // Skip header row - } + const firstPart = parts[0]?.toLowerCase(); + if ( + firstPart === "email" || + firstPart === "e-mail" || + firstPart === "email address" + ) { + return null; } - const email = parts[0]!.toLowerCase(); + const getHeader = (index: number) => headers?.[index]?.trim().toLowerCase(); + + const email = ( + headers + ? parts[ + headers.findIndex((header) => { + const normalized = header.trim().toLowerCase(); + return ( + normalized === "email" || + normalized === "e-mail" || + normalized === "email address" + ); + }) + ] + : parts[0] + )?.toLowerCase(); // Skip if doesn't look like an email - if (!email.includes("@")) return null; + if (!email || !email.includes("@")) return null; // Parse subscribed value (support CSV export format: Email, First Name, Last Name, Subscribed, ...) let subscribed: boolean | undefined = undefined; let firstName: string | undefined = undefined; let lastName: string | undefined = undefined; + const properties: Record = {}; + + if (headers) { + for (let i = 0; i < parts.length; i++) { + const header = getHeader(i); + if (!header) { + continue; + } + + if ( + header === "email" || + header === "e-mail" || + header === "email address" + ) { + continue; + } + + if (header === "firstname" || header === "first name") { + firstName = parts[i] || undefined; + continue; + } + + if (header === "lastname" || header === "last name") { + lastName = parts[i] || undefined; + continue; + } + + if (header === "subscribed") { + const subscribedValue = parts[i]?.toLowerCase(); + if (subscribedValue === "yes" || subscribedValue === "true") { + subscribed = true; + } else if (subscribedValue === "no" || subscribedValue === "false") { + subscribed = false; + } + continue; + } + + if (parts[i]) { + properties[headers[i]!.trim()] = parts[i]!; + } + } + + return { + email, + firstName, + lastName, + subscribed, + properties: Object.keys(properties).length > 0 ? properties : undefined, + }; + } if (parts.length >= 4) { // Could be: email,firstName,lastName,subscribed @@ -152,6 +216,7 @@ export default function BulkUploadContacts({ email, firstName, lastName, + properties: Object.keys(properties).length > 0 ? properties : undefined, subscribed, }; }; @@ -159,9 +224,22 @@ export default function BulkUploadContacts({ const parseContacts = (text: string): ParsedContact[] => { const lines = text.split("\n"); const contactsMap = new Map(); + const firstLineParts = lines[0] + ?.split(",") + .map((part) => part.trim().replace(/^"|"$/g, "")); + const hasHeader = + firstLineParts?.some((part) => { + const normalized = part.toLowerCase(); + return ( + normalized === "email" || + normalized === "e-mail" || + normalized === "email address" + ); + }) ?? false; + const headers = hasHeader ? firstLineParts : undefined; for (let i = 0; i < lines.length; i++) { - const parsed = parseContactLine(lines[i]!, i === 0); + const parsed = parseContactLine(lines[i]!, headers); if (parsed) { // Use email as key to deduplicate if (!contactsMap.has(parsed.email)) { @@ -267,6 +345,7 @@ export default function BulkUploadContacts({ email: c.email, firstName: c.firstName, lastName: c.lastName, + properties: c.properties, subscribed: c.subscribed, })), }); @@ -385,7 +464,8 @@ Format: email,firstName,lastName,subscribed (all fields except email are optiona : "Upload a .txt or .csv file or drag and drop here"}

- Format: email,firstName,lastName,subscribed (one per line) + Format: email,firstName,lastName,subscribed (+ optional + custom columns)

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 d0db475e..bbbd3877 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx @@ -72,10 +72,12 @@ export default function ContactList({ contactBookId, contactBookName, doubleOptInEnabled, + contactBookVariables, }: { contactBookId: string; contactBookName?: string; doubleOptInEnabled?: boolean; + contactBookVariables?: string[]; }) { const [page, setPage] = useUrlState("page", "1"); const [status, setStatus] = useUrlState("status"); @@ -141,6 +143,7 @@ export default function ContactList({ "Subscribed", "Unsubscribe Reason", "Created At", + ...(contactBookVariables ?? []), ]; // CSV Rows @@ -151,6 +154,13 @@ export default function ContactList({ escapeCell(contact.subscribed ? "Yes" : "No"), escapeCell(contact.unsubscribeReason ?? ""), escapeCell(contact.createdAt.toISOString()), + ...(contactBookVariables ?? []).map((variable) => + escapeCell( + ((contact.properties as Record | undefined)?.[ + variable + ] ?? "") as string, + ), + ), ]); // Build CSV with UTF-8 BOM @@ -314,7 +324,10 @@ export default function ContactList({ email={contact.email} /> ) : null} - + diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx index f4da6dc9..3eda587c 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx @@ -12,7 +12,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -20,9 +19,8 @@ import { } from "@usesend/ui/src/form"; import { api } from "~/trpc/react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Edit } from "lucide-react"; -import { useRouter } from "next/navigation"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -34,17 +32,39 @@ const contactSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), firstName: z.string().optional(), lastName: z.string().optional(), + properties: z.record(z.string()).optional(), subscribed: z.boolean().optional(), }); export const EditContact: React.FC<{ contact: Partial & { id: string; contactBookId: string }; -}> = ({ contact }) => { + contactBookVariables?: string[]; +}> = ({ contact, contactBookVariables }) => { const [open, setOpen] = useState(false); const updateContactMutation = api.contacts.updateContact.useMutation(); + const initialVariableValues = useMemo(() => { + const contactProperties = + contact.properties && typeof contact.properties === "object" + ? (contact.properties as Record) + : {}; + + return (contactBookVariables ?? []).reduce( + (acc, variable) => { + const propertyValue = contactProperties[variable]; + acc[variable] = + typeof propertyValue === "string" || + typeof propertyValue === "number" || + typeof propertyValue === "boolean" + ? String(propertyValue) + : ""; + return acc; + }, + {} as Record, + ); + }, [contact.properties, contactBookVariables]); + const [variableValues, setVariableValues] = useState(initialVariableValues); const utils = api.useUtils(); - const router = useRouter(); const contactForm = useForm>({ resolver: zodResolver(contactSchema), @@ -62,6 +82,9 @@ export const EditContact: React.FC<{ contactId: contact.id, contactBookId: contact.contactBookId, ...values, + properties: Object.fromEntries( + Object.entries(variableValues).filter(([, value]) => value.trim()), + ), }, { onSuccess: async () => { @@ -72,7 +95,7 @@ export const EditContact: React.FC<{ onError: async (error) => { toast.error(error.message); }, - } + }, ); } @@ -151,6 +174,23 @@ export const EditContact: React.FC<{ )} /> + {(contactBookVariables ?? []).map((variable) => ( + + {variable} + + { + setVariableValues((prev) => ({ + ...prev, + [variable]: e.target.value, + })); + }} + /> + + + ))}
+
+

Variables

+
+ {(contactBookDetailQuery.data?.variables ?? []).length > 0 ? ( + contactBookDetailQuery.data?.variables.map((variable) => ( + + {variable} + + )) + ) : ( + -- + )} +
+
@@ -329,6 +346,7 @@ export default function ContactsPage({ contactBookId={contactBookId} contactBookName={contactBookDetailQuery.data?.name} doubleOptInEnabled={contactBookDetailQuery.data?.doubleOptInEnabled} + contactBookVariables={contactBookDetailQuery.data?.variables} /> diff --git a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx index 02801728..3e097662 100644 --- a/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx @@ -33,6 +33,7 @@ const contactBookSchema = z.object({ name: z.string({ required_error: "Name is required" }).min(1, { message: "Name is required", }), + variables: z.string().optional(), }); export default function AddContactBook() { @@ -51,6 +52,7 @@ export default function AddContactBook() { resolver: zodResolver(contactBookSchema), defaultValues: { name: "", + variables: "", }, }); @@ -63,6 +65,10 @@ export default function AddContactBook() { createContactBookMutation.mutate( { name: values.name, + variables: values.variables + ?.split(",") + .map((variable) => variable.trim()) + .filter(Boolean), }, { onSuccess: () => { @@ -71,6 +77,9 @@ export default function AddContactBook() { setOpen(false); toast.success("Contact book created successfully"); }, + onError: (error) => { + toast.error(error.message); + }, }, ); } @@ -125,6 +134,25 @@ export default function AddContactBook() { )} /> + ( + + Variables + + + + + Optional comma-separated variable names for campaign + personalization. + + + )} + />
- +
diff --git a/apps/web/src/lib/contact-properties.ts b/apps/web/src/lib/contact-properties.ts new file mode 100644 index 00000000..35374576 --- /dev/null +++ b/apps/web/src/lib/contact-properties.ts @@ -0,0 +1,88 @@ +export function getCanonicalContactVariableName( + key: string, + allowedVariables: string[] = [], +) { + const normalizedKey = key.trim().toLowerCase(); + + return allowedVariables.find( + (variable) => variable.toLowerCase() === normalizedKey, + ); +} + +export function normalizeContactProperties( + properties?: Record | null, + allowedVariables: string[] = [], +) { + const normalizedProperties: Record = {}; + + for (const [key, value] of Object.entries(properties ?? {})) { + const canonicalKey = getCanonicalContactVariableName(key, allowedVariables); + normalizedProperties[canonicalKey ?? key] = value; + } + + return normalizedProperties; +} + +export function getContactPropertyValue( + properties: Record | null | undefined, + key: string, + allowedVariables: string[] = [], +) { + const normalizedKey = key.toLowerCase(); + const canonicalKey = getCanonicalContactVariableName(key, allowedVariables); + + const propertyKey = Object.keys(properties ?? {}).find((candidate) => { + const normalizedCandidate = candidate.toLowerCase(); + + return ( + normalizedCandidate === normalizedKey || + normalizedCandidate === canonicalKey?.toLowerCase() + ); + }); + + const propertyValue = propertyKey ? properties?.[propertyKey] : undefined; + + if ( + typeof propertyValue === "string" || + typeof propertyValue === "number" || + typeof propertyValue === "boolean" + ) { + return String(propertyValue); + } + + return undefined; +} + +export function mergeContactProperties( + existingProperties?: Record | null, + incomingProperties?: Record | null, + allowedVariables: string[] = [], +) { + return { + ...normalizeContactProperties(existingProperties, allowedVariables), + ...normalizeContactProperties(incomingProperties, allowedVariables), + }; +} + +export function replaceContactVariableValues( + existingProperties: Record | null | undefined, + variableValues: Record, + allowedVariables: string[] = [], +) { + const normalizedExistingProperties = normalizeContactProperties( + existingProperties, + allowedVariables, + ); + + for (const key of Object.keys(normalizedExistingProperties)) { + if (getCanonicalContactVariableName(key, allowedVariables)) { + delete normalizedExistingProperties[key]; + } + } + + return mergeContactProperties( + normalizedExistingProperties, + variableValues, + allowedVariables, + ); +} diff --git a/apps/web/src/lib/contact-properties.unit.test.ts b/apps/web/src/lib/contact-properties.unit.test.ts new file mode 100644 index 00000000..5411366a --- /dev/null +++ b/apps/web/src/lib/contact-properties.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + getContactPropertyValue, + normalizeContactProperties, + replaceContactVariableValues, +} from "~/lib/contact-properties"; + +describe("contact-properties", () => { + it("normalizes registered property keys to the canonical variable casing", () => { + expect( + normalizeContactProperties( + { + Company: "Acme", + tier: "gold", + PlanName: "Pro", + }, + ["company", "planName"], + ), + ).toEqual({ + company: "Acme", + tier: "gold", + planName: "Pro", + }); + }); + + it("reads property values case-insensitively for registered variables", () => { + expect( + getContactPropertyValue( + { + Company: "Acme", + }, + "company", + ["company"], + ), + ).toBe("Acme"); + }); + + it("replaces registry-backed values while preserving unrelated properties", () => { + expect( + replaceContactVariableValues( + { + Company: "Old Co", + tier: "gold", + notes: "keep me", + }, + { + company: "New Co", + }, + ["company", "plan"], + ), + ).toEqual({ + notes: "keep me", + tier: "gold", + company: "New Co", + }); + }); +}); diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index d0103a33..9ae6519b 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -2,6 +2,7 @@ import { EmailRenderer } from "@usesend/email-editor/src/renderer"; import { db } from "../db"; import { createHash } from "crypto"; import { env } from "~/env"; +import { getContactPropertyValue } from "~/lib/contact-properties"; import { Campaign, Contact, @@ -75,23 +76,11 @@ function getContactReplacementValue({ return undefined; } - const contactProperties = contact.properties as Record; - const propertyKey = Object.keys(contactProperties).find( - (candidate) => candidate.toLowerCase() === matchedVariable.toLowerCase(), + return getContactPropertyValue( + contact.properties as Record, + matchedVariable, + allowedVariables, ); - const propertyValue = propertyKey - ? contactProperties[propertyKey] - : undefined; - - if ( - typeof propertyValue === "string" || - typeof propertyValue === "number" || - typeof propertyValue === "boolean" - ) { - return String(propertyValue); - } - - return undefined; } function createCaseInsensitiveVariableValues( diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index 314470f4..cd04477b 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -3,6 +3,10 @@ import { type ContactPayload, type ContactWebhookEventType, } from "@usesend/lib/src/webhook/webhook-events"; +import { + mergeContactProperties, + normalizeContactProperties, +} from "~/lib/contact-properties"; import { db } from "../db"; import { ContactQueueService } from "./contact-queue-service"; import { WebhookService } from "./webhook-service"; @@ -29,6 +33,7 @@ export async function addOrUpdateContact( select: { doubleOptInEnabled: true, teamId: true, + variables: true, }, }); @@ -75,6 +80,11 @@ export async function addOrUpdateContact( existingContact === null && !isExplicitUnsubscribeRequest; + const normalizedProperties = normalizeContactProperties( + contact.properties, + contactBook.variables, + ); + const savedContact = await db.contact.upsert({ where: { contactBookId_email: { @@ -87,7 +97,7 @@ export async function addOrUpdateContact( email: contact.email, firstName: contact.firstName, lastName: contact.lastName, - properties: contact.properties ?? {}, + properties: normalizedProperties, subscribed: shouldCreatePendingContact ? false : (contact.subscribed ?? true), @@ -100,7 +110,7 @@ export async function addOrUpdateContact( update: { firstName: contact.firstName, lastName: contact.lastName, - properties: contact.properties ?? {}, + properties: normalizedProperties, ...(subscribedValue !== undefined ? { subscribed: subscribedValue, @@ -168,12 +178,27 @@ export async function updateContactInContactBook( return null; } + const contactBook = await db.contactBook.findUnique({ + where: { id: contactBookId }, + select: { variables: true }, + }); + + const mergedProperties = + contact.properties === undefined + ? undefined + : mergeContactProperties( + (existingContact.properties as Record | null) ?? {}, + contact.properties, + contactBook?.variables ?? [], + ); + const updatedContact = await db.contact.update({ where: { id: contactId, }, data: { ...contact, + ...(mergedProperties !== undefined ? { properties: mergedProperties } : {}), ...(contact.subscribed !== undefined ? { unsubscribeReason: contact.subscribed diff --git a/apps/web/src/server/service/contact-service.unit.test.ts b/apps/web/src/server/service/contact-service.unit.test.ts index 9ce9d412..3cb7fd81 100644 --- a/apps/web/src/server/service/contact-service.unit.test.ts +++ b/apps/web/src/server/service/contact-service.unit.test.ts @@ -14,6 +14,7 @@ const { contact: { findFirst: vi.fn(), findUnique: vi.fn(), + update: vi.fn(), upsert: vi.fn(), }, }, @@ -53,6 +54,7 @@ vi.mock("~/server/logger/log", () => ({ import { addOrUpdateContact, resendDoubleOptInConfirmationInContactBook, + updateContactInContactBook, } from "~/server/service/contact-service"; const createdAt = new Date("2026-02-08T00:00:00.000Z"); @@ -62,6 +64,7 @@ describe("contact-service addOrUpdateContact", () => { mockDb.contactBook.findUnique.mockReset(); mockDb.contact.findFirst.mockReset(); mockDb.contact.findUnique.mockReset(); + mockDb.contact.update.mockReset(); mockDb.contact.upsert.mockReset(); mockWebhookEmit.mockReset(); mockSendDoubleOptInConfirmationEmail.mockReset(); @@ -233,6 +236,48 @@ describe("contact-service addOrUpdateContact", () => { ); }); + it("canonicalizes registered property keys when creating contacts", async () => { + mockDb.contactBook.findUnique.mockResolvedValue({ + doubleOptInEnabled: false, + teamId: 7, + variables: ["company"], + }); + mockDb.contact.findUnique.mockResolvedValue(null); + mockDb.contact.upsert.mockResolvedValue({ + id: "contact_8", + email: "frank@example.com", + contactBookId: "book_1", + subscribed: true, + properties: { company: "Acme", tier: "gold" }, + firstName: null, + lastName: null, + createdAt, + updatedAt: createdAt, + }); + + await addOrUpdateContact( + "book_1", + { + email: "frank@example.com", + properties: { + Company: "Acme", + tier: "gold", + }, + }, + 7, + ); + + const upsertArgs = mockDb.contact.upsert.mock.calls[0]?.[0]; + expect(upsertArgs.create.properties).toEqual({ + company: "Acme", + tier: "gold", + }); + expect(upsertArgs.update.properties).toEqual({ + company: "Acme", + tier: "gold", + }); + }); + it("throws when contact book does not exist", async () => { mockDb.contactBook.findUnique.mockResolvedValue(null); @@ -325,4 +370,59 @@ describe("contact-service addOrUpdateContact", () => { ).resolves.toBeNull(); expect(mockSendDoubleOptInConfirmationEmail).not.toHaveBeenCalled(); }); + + it("merges contact properties on update and canonicalizes registry variables", async () => { + mockDb.contact.findFirst.mockResolvedValue({ + id: "contact_9", + email: "grace@example.com", + contactBookId: "book_1", + subscribed: true, + unsubscribeReason: null, + properties: { + Company: "Old Co", + notes: "keep me", + }, + createdAt, + updatedAt: createdAt, + }); + mockDb.contactBook.findUnique.mockResolvedValue({ + variables: ["company", "plan"], + }); + mockDb.contact.update.mockResolvedValue({ + id: "contact_9", + email: "grace@example.com", + contactBookId: "book_1", + subscribed: true, + unsubscribeReason: null, + properties: { + company: "New Co", + notes: "keep me", + }, + createdAt, + updatedAt: createdAt, + }); + + await updateContactInContactBook( + "contact_9", + "book_1", + { + properties: { + company: "New Co", + }, + }, + 7, + ); + + expect(mockDb.contact.update).toHaveBeenCalledWith({ + where: { + id: "contact_9", + }, + data: { + properties: { + company: "New Co", + notes: "keep me", + }, + }, + }); + }); }); From f76decd541610f15aa5b299f3711174402b585b7 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 7 Mar 2026 23:43:04 +1100 Subject: [PATCH 6/7] stuff --- .../campaigns/[campaignId]/edit/page.tsx | 5 + .../contacts/[contactBookId]/add-contact.tsx | 43 ++++-- .../[contactBookId]/bulk-upload-contacts.tsx | 43 ++++-- .../contacts/[contactBookId]/page.tsx | 136 ++++++++++++++++-- .../contacts/delete-contact-book.tsx | 22 ++- .../contacts/edit-contact-book.tsx | 50 +++++-- packages/email-editor/src/editor.tsx | 8 +- packages/email-editor/src/extensions/index.ts | 7 +- packages/email-editor/src/nodes/variable.tsx | 25 +++- 9 files changed, 285 insertions(+), 54 deletions(-) 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 349bb011..2cef3002 100644 --- a/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx +++ b/apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx @@ -173,6 +173,9 @@ function CampaignEditor({ return Array.from(new Set([...baseVariables, ...registryVariables])); }, [contactBook]); + const variableSuggestionsHelperText = contactBookId + ? undefined + : "Select the contact book for related variable"; return (
@@ -441,6 +444,7 @@ function CampaignEditor({
{ setJson(content.getJSON()); @@ -448,6 +452,7 @@ function CampaignEditor({ deboucedUpdateCampaign(); }} variables={editorVariables} + variableSuggestionsHelperText={variableSuggestionsHelperText} uploadImage={ campaign.imageUploadSupported ? handleFileChange : undefined } diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx index 2e057af1..eebacf26 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/add-contact.tsx @@ -26,6 +26,7 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@usesend/ui/src/toaster"; +import type { ReactNode } from "react"; const contactsSchema = z.object({ contacts: z.string({ required_error: "Contacts are required" }).min(1, { @@ -35,8 +36,14 @@ const contactsSchema = z.object({ export default function AddContact({ contactBookId, + trigger, + open: controlledOpen, + onOpenChange, }: { contactBookId: string; + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { const [open, setOpen] = useState(false); @@ -50,6 +57,14 @@ export default function AddContact({ }); const utils = api.useUtils(); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); async function onContactsAdd(values: z.infer) { const contactsArray = values.contacts.split(",").map((email) => ({ @@ -64,7 +79,11 @@ export default function AddContact({ { onSuccess: async () => { utils.contacts.contacts.invalidate(); - setOpen(false); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } toast.success("Contacts queued for processing"); }, onError: async (error) => { @@ -76,15 +95,21 @@ export default function AddContact({ return ( (_open !== open ? setOpen(_open) : null)} + open={controlledOpen ?? open} + onOpenChange={(nextOpen) => { + if (controlledOpen === undefined) { + if (nextOpen !== open) { + setOpen(nextOpen); + } + return; + } + + onOpenChange?.(nextOpen); + }} > - - - + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Add new contacts diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx index f245a412..56c3071e 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, type ReactNode } from "react"; import { api } from "~/trpc/react"; import { getCanonicalContactVariableName } from "~/lib/contact-properties"; import { @@ -30,6 +30,9 @@ import { toast } from "@usesend/ui/src/toaster"; interface BulkUploadContactsProps { contactBookId: string; contactBookVariables?: string[]; + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } interface ParsedContact { @@ -44,12 +47,23 @@ interface ParsedContact { export default function BulkUploadContacts({ contactBookId, contactBookVariables, + trigger, + open: controlledOpen, + onOpenChange, }: BulkUploadContactsProps) { const [open, setOpen] = useState(false); const [inputText, setInputText] = useState(""); const [error, setError] = useState(null); const [processing, setProcessing] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); const utils = api.useUtils(); @@ -71,7 +85,11 @@ export default function BulkUploadContacts({ setInputText(""); setError(null); setProcessing(false); - setOpen(false); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } }; const validateEmail = (email: string): boolean => { @@ -384,13 +402,20 @@ export default function BulkUploadContacts({ ); return ( - - - - + { + if (controlledOpen === undefined) { + setOpen(nextOpen); + return; + } + + onOpenChange?.(nextOpen); + }} + > + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Bulk Upload Contacts diff --git a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx index 00cfe4e9..250e8436 100644 --- a/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx +++ b/apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx @@ -23,7 +23,7 @@ import { import { Button } from "@usesend/ui/src/button"; import { Switch } from "@usesend/ui/src/switch"; import { useTheme } from "@usesend/ui"; -import { use } from "react"; +import { use, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@usesend/ui/src/card"; import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy"; import { @@ -35,7 +35,129 @@ import { Megaphone, Shield, ChevronRight, + MoreVertical, + Plus, + Upload, + Edit, + Trash2, } from "lucide-react"; +import EditContactBook from "../edit-contact-book"; +import DeleteContactBook from "../delete-contact-book"; + +function ContactBookDetailActions({ + contactBookId, + contactBookName, + contactBookVariables, +}: { + contactBookId: string; + contactBookName?: string; + contactBookVariables?: string[]; +}) { + const [open, setOpen] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); + const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + return ( + <> + + + + + +
+ + + {contactBookName ? ( + + ) : null} + {contactBookName ? ( + + ) : null} +
+
+
+ + + + {contactBookName ? ( + + ) : null} + {contactBookName ? ( + + ) : null} + + ); +} export default function ContactsPage({ params, @@ -132,13 +254,11 @@ export default function ContactsPage({
-
- - -
+
diff --git a/apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx index 0886d609..d1e4ee07 100644 --- a/apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/delete-contact-book.tsx @@ -7,10 +7,14 @@ import { ContactBook } from "@prisma/client"; import { toast } from "@usesend/ui/src/toaster"; import { Trash2 } from "lucide-react"; import { z } from "zod"; +import type { ReactNode } from "react"; export const DeleteContactBook: React.FC<{ contactBook: Partial & { id: string }; -}> = ({ contactBook }) => { + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ contactBook, trigger, open, onOpenChange }) => { const deleteContactBookMutation = api.contacts.deleteContactBook.useMutation(); const utils = api.useUtils(); @@ -42,6 +46,14 @@ export const DeleteContactBook: React.FC<{ ); } + const dialogTrigger = + trigger ?? + (open === undefined ? ( + + ) : null); + return ( - - - } + open={open} + onOpenChange={onOpenChange} + trigger={dialogTrigger} confirmLabel="Delete Contact Book" /> ); diff --git a/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx b/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx index 74f31162..f136ff0d 100644 --- a/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx +++ b/apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx @@ -25,6 +25,7 @@ import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "@usesend/ui/src/toaster"; +import type { ReactNode } from "react"; const contactBookSchema = z.object({ name: z.string().min(1, { message: "Name is required" }), @@ -33,12 +34,27 @@ const contactBookSchema = z.object({ export const EditContactBook: React.FC<{ contactBook: { id: string; name: string; variables?: string[] }; -}> = ({ contactBook }) => { + trigger?: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}> = ({ contactBook, trigger, open: controlledOpen, onOpenChange }) => { const [open, setOpen] = useState(false); const updateContactBookMutation = api.contacts.updateContactBook.useMutation(); const utils = api.useUtils(); + const dialogTrigger = + trigger ?? + (controlledOpen === undefined ? ( + + ) : null); const contactBookForm = useForm>({ resolver: zodResolver(contactBookSchema), @@ -63,7 +79,11 @@ export const EditContactBook: React.FC<{ { onSuccess: async () => { utils.contacts.getContactBooks.invalidate(); - setOpen(false); + if (controlledOpen === undefined) { + setOpen(false); + } else { + onOpenChange?.(false); + } toast.success("Contact book updated successfully"); }, onError: async (error) => { @@ -75,19 +95,21 @@ export const EditContactBook: React.FC<{ return ( (_open !== open ? setOpen(_open) : null)} + open={controlledOpen ?? open} + onOpenChange={(nextOpen) => { + if (controlledOpen === undefined) { + if (nextOpen !== open) { + setOpen(nextOpen); + } + return; + } + + onOpenChange?.(nextOpen); + }} > - - - + {dialogTrigger ? ( + {dialogTrigger} + ) : null} Edit Contact Book diff --git a/packages/email-editor/src/editor.tsx b/packages/email-editor/src/editor.tsx index d31d0b51..96e0eaa4 100644 --- a/packages/email-editor/src/editor.tsx +++ b/packages/email-editor/src/editor.tsx @@ -69,6 +69,7 @@ export type EditorProps = { initialContent?: Content; variables?: Array; uploadImage?: UploadFn; + variableSuggestionsHelperText?: string; }; export const Editor: React.FC = ({ @@ -76,6 +77,7 @@ export const Editor: React.FC = ({ initialContent, variables, uploadImage, + variableSuggestionsHelperText, }) => { const menuContainerRef = useRef(null); @@ -96,7 +98,11 @@ export const Editor: React.FC = ({ }, }, }, - extensions: extensions({ variables, uploadImage }), + extensions: extensions({ + variables, + uploadImage, + variableSuggestionsHelperText, + }), onUpdate: ({ editor }) => { onUpdate?.(editor); }, diff --git a/packages/email-editor/src/extensions/index.ts b/packages/email-editor/src/extensions/index.ts index 4587c2b7..c086279c 100644 --- a/packages/email-editor/src/extensions/index.ts +++ b/packages/email-editor/src/extensions/index.ts @@ -21,9 +21,11 @@ import { ResizableImageExtension, UploadFn } from "./ImageExtension"; export function extensions({ variables, uploadImage, + variableSuggestionsHelperText, }: { variables?: Array; uploadImage?: UploadFn; + variableSuggestionsHelperText?: string; }) { const extensions = [ StarterKit.configure({ @@ -79,7 +81,10 @@ export function extensions({ ButtonExtension, GlobalDragHandle, VariableExtension.configure({ - suggestion: getVariableSuggestions(variables), + suggestion: getVariableSuggestions( + variables, + variableSuggestionsHelperText, + ), }), UnsubscribeFooterExtension, ResizableImageExtension.configure({ uploadImage }), diff --git a/packages/email-editor/src/nodes/variable.tsx b/packages/email-editor/src/nodes/variable.tsx index 0788da5b..dfac748e 100644 --- a/packages/email-editor/src/nodes/variable.tsx +++ b/packages/email-editor/src/nodes/variable.tsx @@ -36,7 +36,7 @@ export const VariableList = forwardRef((props: any, ref) => { onKeyDown: ({ event }: { event: KeyboardEvent }) => { if (event.key === "ArrowUp") { setSelectedIndex( - (selectedIndex + props.items.length - 1) % props.items.length + (selectedIndex + props.items.length - 1) % props.items.length, ); return true; } @@ -64,7 +64,7 @@ export const VariableList = forwardRef((props: any, ref) => { onClick={() => selectItem(index)} className={cn( "flex w-full space-x-2 rounded-md px-2 py-1 text-left text-sm text-gray-900 hover:bg-gray-100", - index === selectedIndex ? "bg-gray-200" : "bg-white" + index === selectedIndex ? "bg-gray-200" : "bg-white", )} > {item} @@ -75,6 +75,11 @@ export const VariableList = forwardRef((props: any, ref) => { No result )} + {props.helperText ? ( +
+ {props.helperText} +
+ ) : null}
); }); @@ -82,14 +87,15 @@ export const VariableList = forwardRef((props: any, ref) => { VariableList.displayName = "VariableList"; export function getVariableSuggestions( - variables: Array = [] + variables: Array = [], + helperText?: string, ): Omit { return { items: ({ query }) => { return variables .concat(query.length > 0 ? [query] : []) .filter((item) => item.toLowerCase().startsWith(query.toLowerCase())) - .slice(0, 5); + .slice(0, 10); }, render: () => { @@ -102,6 +108,10 @@ export function getVariableSuggestions( props, editor: props.editor, }); + component.updateProps({ + ...props, + helperText, + }); if (!props.clientRect) { return; @@ -119,7 +129,10 @@ export function getVariableSuggestions( }, onUpdate(props) { - component.updateProps(props); + component.updateProps({ + ...props, + helperText, + }); if (!props.clientRect) { return; @@ -180,7 +193,7 @@ export function VariableComponent(props: NodeViewProps) {