Skip to content

feat: add contact-book variable registry for campaign personalization#359

Open
KMKoushik wants to merge 4 commits intomainfrom
feat/contact-book-variable-registry
Open

feat: add contact-book variable registry for campaign personalization#359
KMKoushik wants to merge 4 commits intomainfrom
feat/contact-book-variable-registry

Conversation

@KMKoushik
Copy link
Member

@KMKoushik KMKoushik commented Feb 24, 2026

Summary

  • add a dedicated ContactBook.variables registry (plus migration) and validate/normalize variable names with reserved key protection for email, firstName, and lastName
  • update TRPC + public contact-book APIs and schemas to accept/return variables, and wire contact-book create/edit UI to manage comma-separated variable registries
  • use built-ins + contact-book registry for campaign editor variable suggestions and restrict campaign variable replacement to allowed keys while still permitting extra contact properties to be stored
  • extend contacts workflows to carry custom property columns in bulk upload, edit known variable values on contacts, and include registry variables in CSV export

Verification

  • unable to run lint/tests in this VM because pnpm is not installed (pnpm: command not found)

Summary by cubic

Adds a contact-book variable registry for controlled campaign personalization. Campaigns now replace only built‑in and whitelisted variables (case-insensitive with fallbacks), and custom contact properties are handled across upload, edit, list, and export with normalized casing and preservation of non‑registry fields.

  • New Features

    • DB: ContactBook.variables (string[]) with normalization, invalid-character checks, and reserved name protection for email/firstName/lastName.
    • API: create/update accept variables; get/list return variables; schemas expose variables; contact responses include properties; OpenAPI updated.
    • Campaigns: editor suggests built-ins + registry variables; sending replaces only allowed keys; matching is case-insensitive and supports {{...,fallback=...}}; unknown placeholders are preserved.
    • Upload/export: header detection is case-insensitive and normalizes to registry casing; custom columns are stored in properties; CSV export adds registry variable columns.
    • UI: Add/Edit Contact Book manage comma-separated variables with validation; Edit Contact shows inputs for registry variables and preserves non-registry properties on save; Contact Book page lists variables.
  • Migration

    • Run Prisma migrations.
    • When creating/updating contact books, pass variables (array via API or comma-separated in UI). Reserved names are rejected.

Written for commit cebc799. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Contact books can store custom variables; shown in Details and editable when creating or editing a contact book.
    • Campaign editor and rendering support contact-book variables as placeholders.
    • Bulk CSV upload accepts headers and optional custom columns; uploaded contacts can include per-contact properties.
    • Contact list export and contact edit/profile surfaces include custom variable values.
  • Tests

    • Added unit tests for contact property utilities and contact-book creation handling.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2463fd9 and cebc799.

📒 Files selected for processing (5)
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx
  • apps/web/src/lib/contact-properties.ts
  • apps/web/src/lib/contact-properties.unit.test.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/lib/contact-properties.unit.test.ts

Walkthrough

Adds a new variables: String[] column to the ContactBook model and a DB migration. Surfaces variables in create/update/get public APIs and service layer with normalization and validation. UI forms and pages were extended to edit and display contact-book variables. Contacts import/export and internal contact model handling were extended to carry per-contact properties. Campaign rendering and job payloads were updated to accept and use allowed variables (built-in variables plus contact-book variables) for template substitution.

Possibly related PRs

  • add upload contacts support #314: Modifies bulk-upload-contacts parsing and the ParsedContact shape to add header-aware parsing and per-contact properties used by this change.
  • feat: contact books public api #336: Introduces ContactBook variables in API/schema surfaces and handlers, directly related to adding variables persistence and API fields.
  • add campaign api #274: Changes campaign-service rendering and job payloads, related to propagating allowed variables into email rendering and job processing.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a contact-book variable registry system for campaign personalization, which is the core feature implemented across schema, API, UI, and service layers.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

188-247: ⚠️ Potential issue | 🟡 Minor

EmailRenderer variable matching is case-sensitive while HTML path is case-insensitive—this creates inconsistency.

In the JSON content path, EmailRenderer uses direct property access (this.variableValues[variable]) with no case normalization, making it strictly case-sensitive. However, in the HTML path, replaceContactVariables normalizes all keys to lowercase and compares allowedVariables case-insensitively.

This means if a custom variable is registered as "FirstName" (mixed case):

  • The HTML path correctly matches it (normalizes both to "firstname")
  • The JSON path may fail to substitute it, depending on how the variable is referenced in the JSON content

Consider normalizing variable keys to a consistent casing (e.g., lowercase) when building variableValues for EmailRenderer, or document that custom variables must match the exact casing used in the JSON content.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/campaign-service.ts` around lines 188 - 247,
renderCampaignHtmlForContact builds variableValues for EmailRenderer in a
case-sensitive way while replaceContactVariables lowercases keys, causing
inconsistent substitutions; update the variableValues construction inside
renderCampaignHtmlForContact so all custom variable keys are normalized (e.g.,
toLowerCase()) when inserted (use the same normalization used by
replaceContactVariables/getContactReplacementValue) and ensure the renderer
receives both normalized keys and core keys (email, firstName, lastName) in the
matching normalized form so JSON-rendered content substitutes consistently with
the HTML path.
🧹 Nitpick comments (6)
apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx (1)

170-175: useMemo dependency should be contactBook, not contactBook?.variables.

contactBook is a render-scope derived value (from .find()), not a ref-stable value. Listing contactBook?.variables instead of contactBook in the dependency array will likely trigger react-hooks/exhaustive-deps lint warnings, and is less semantically clear. Since contactBook changing is the condition that should trigger a recompute, it belongs directly in the deps:

♻️ Proposed fix
  const editorVariables = useMemo(() => {
    const baseVariables = ["email", "firstName", "lastName"];
    const registryVariables = contactBook?.variables ?? [];
    return Array.from(new Set([...baseVariables, ...registryVariables]));
- }, [contactBook?.variables]);
+ }, [contactBook]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/campaigns/[campaignId]/edit/page.tsx around
lines 170 - 175, The useMemo for editorVariables currently depends on
contactBook?.variables but should depend on the whole contactBook because
contactBook is a derived render-scope value; update the dependency array of
useMemo to [contactBook] so editorVariables recomputes whenever contactBook
changes (refer to the editorVariables constant and the useMemo call that reads
contactBook?.variables).
apps/web/src/server/service/campaign-service.ts (1)

111-144: DRY the built-in variable list to prevent drift.

replaceContactVariables hard-codes the built-ins even though BUILT_IN_CONTACT_VARIABLES exists. Using the constant avoids future inconsistencies.

♻️ Suggested refactor
-      const isBuiltIn = ["email", "firstname", "lastname"].includes(
-        normalizedKey,
-      );
+      const isBuiltIn = BUILT_IN_CONTACT_VARIABLES.some(
+        (variable) => variable.toLowerCase() === normalizedKey,
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/campaign-service.ts` around lines 111 - 144, The
function replaceContactVariables currently hard-codes the built-in list
["email","firstname","lastname"]; replace that literal with the existing
constant BUILT_IN_CONTACT_VARIABLES to avoid drift (use the same
case-insensitive check you already do: normalize the compared value with
normalizedKey and compare against BUILT_IN_CONTACT_VARIABLES in a
case-insensitive way), keeping the rest of the logic intact
(CONTACT_VARIABLE_REGEX, allowedVariables check, and getContactReplacementValue
usage).
apps/web/src/server/public-api/api/contacts/create-contact-book.ts (1)

44-63: Skip the extra update when only variables are provided.

createContactBookService already persists variables. The current truthy check triggers an immediate updateContactBook even when only variables are present (or an empty array), causing a redundant write.

♻️ Suggested refactor
-    // Update emoji/properties/variables if provided
-    if (body.emoji || body.properties || body.variables) {
+    // Update emoji/properties if provided (variables already set on create)
+    const needsUpdate =
+      body.emoji !== undefined || body.properties !== undefined;
+    if (needsUpdate) {
       const updated = await updateContactBook(contactBook.id, {
         emoji: body.emoji,
         properties: body.properties,
         variables: body.variables,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/public-api/api/contacts/create-contact-book.ts` around
lines 44 - 63, The code performs an unnecessary update when only body.variables
is present because createContactBookService already saved variables; change the
conditional from if (body.emoji || body.properties || body.variables) to only
run when emoji or properties are explicitly provided (e.g. if (body.emoji !==
undefined || body.properties !== undefined)), and when calling
updateContactBook(contactBook.id, ...) do not include body.variables in the
payload so you avoid redundant writes; keep using the existing symbols
createContactBookService, updateContactBook, contactBook.id, body.emoji,
body.properties, and body.variables to locate and modify the logic.
apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx (1)

148-154: Redundant as string cast.

The ?? "" null-coalescing already narrows the type to string; the trailing as string assertion is unnecessary.

♻️ Suggested cleanup
-      ...(contactBookVariables ?? []).map((variable) =>
-        escapeCell(
-          ((contact.properties as Record<string, string> | undefined)?.[
-            variable
-          ] ?? "") as string,
-        ),
-      ),
+      ...(contactBookVariables ?? []).map((variable) =>
+        escapeCell(
+          (contact.properties as Record<string, string> | undefined)?.[
+            variable
+          ] ?? "",
+        ),
+      ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/contact-list.tsx
around lines 148 - 154, The code maps contactBookVariables to cells using
escapeCell but includes a redundant type assertion "as string" after the
null-coalescing expression; remove the trailing "as string" in the mapping so
the expression ((contact.properties as Record<string, string> |
undefined)?.[variable] ?? "") is passed directly to escapeCell, leaving
contactBookVariables, escapeCell, and contact.properties references unchanged.
apps/web/src/server/public-api/api/contacts/get-contact-book.ts (1)

78-82: Redundant explicit variables field in response.

...contactBook already spreads variables from Prisma; the extra variables: contactBook.variables overrides it with the same value.

♻️ Suggested cleanup
     return c.json({
       ...contactBook,
       properties: contactBook.properties as Record<string, string>,
-      variables: contactBook.variables,
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/public-api/api/contacts/get-contact-book.ts` around lines
78 - 82, The return object in the c.json call redundantly reassigns variables
from contactBook (the spread already includes it); inside the get-contact-book
response, remove the explicit "variables: contactBook.variables" property and
simply return c.json({ ...contactBook, properties: contactBook.properties as
Record<string, string> }) so the spread alone provides variables while keeping
the properties cast.
apps/web/src/server/service/contact-book-service.ts (1)

114-122: ...data spread passes the raw (un-normalized) variables before the normalized override.

When data.variables is defined, ...data spreads it first, then { variables: normalizedVariables } overwrites it. This is functionally correct (normalized value always wins), but it means Prisma momentarily receives the un-normalized value in the spread object, which the subsequent key override corrects. The pattern is safe but slightly fragile. Extracting variables from data before the spread makes the intent explicit and prevents confusion.

♻️ Suggested alternative
+  const { variables: _rawVariables, ...restData } = data;
   return db.contactBook.update({
     where: { id: contactBookId },
     data: {
-      ...data,
-      ...(normalizedVariables !== undefined
-        ? { variables: normalizedVariables }
-        : {}),
+      ...restData,
+      ...(normalizedVariables !== undefined ? { variables: normalizedVariables } : {}),
     },
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/contact-book-service.ts` around lines 114 - 122,
The update call currently spreads ...data which may include the raw variables
before the normalized override; refactor the code that calls
db.contactBook.update to destructure variables out of data first (e.g., const {
variables, ...rest } = data), then pass rest plus the normalized variables (if
defined) into db.contactBook.update so the raw variables are never part of the
spread; update the db.contactBook.update call to use the new rest object and
conditional variables: normalizedVariables to make the intent explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx:
- Around line 224-243: The header-detection in parseContacts can misidentify a
data row as a header if a cell contains the word "Email"; update parseContacts
so hasHeader only returns true when the first line both contains a header label
(email/e-mail/email address) AND the first-line cells do not look like actual
data (e.g., none match an email regex and not all cells are numeric/typical data
values). Concretely, in parseContacts before setting headers, validate
firstLineParts by running a simple email regex (and optionally a numeric check)
against each part and only treat the row as a header if a header label exists
AND there are zero email-looking parts (or a low fraction of data-like parts);
keep using parseContactLine(headers) afterward.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/edit-contact.tsx:
- Around line 177-193: The FormLabel and Input for dynamic variables are not
linked, so generate a stable unique id per variable (e.g., using the variable
name and the contactBookId or another stable identifier) inside the
contactBookVariables.map, set that id on the Input and set FormLabel's htmlFor
to the same id (sanitize variable to remove spaces/unsafe chars); update the map
rendering that uses FormItem/FormLabel/FormControl/Input and keep using
variableValues/setVariableValues for state so each label is properly associated
with its input for screen readers.
- Around line 45-65: The state variableValues is only initialized once from
initialVariableValues and won't reflect later changes to contactBookVariables;
add a useEffect that runs when initialVariableValues changes and merges in any
new keys without clobbering user edits by calling setVariableValues(prev => ({
...initialVariableValues, ...prev })) so new variables are added (from
initialVariableValues) while existing user-typed values in variableValues are
preserved; reference the initialVariableValues, variableValues and
setVariableValues identifiers and place the effect near their declarations.

In `@apps/web/src/server/service/contact-variable-service.ts`:
- Around line 1-33: The validateContactBookVariables function currently only
checks RESERVED_CONTACT_VARIABLES; add a format check that enforces the same
placeholder regex used for campaign parsing (e.g., /^[A-Za-z0-9_]+$/) so names
like "first-name" or "company name" are rejected; define a shared constant
(e.g., CONTACT_VARIABLE_REGEX) near RESERVED_CONTACT_VARIABLES and use it in
validateContactBookVariables to throw a clear error when a variable doesn't
match the allowed pattern, ensuring normalizeContactBookVariables still
trims/dedups but that validateContactBookVariables validates against both
reserved names and the regex.

---

Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 188-247: renderCampaignHtmlForContact builds variableValues for
EmailRenderer in a case-sensitive way while replaceContactVariables lowercases
keys, causing inconsistent substitutions; update the variableValues construction
inside renderCampaignHtmlForContact so all custom variable keys are normalized
(e.g., toLowerCase()) when inserted (use the same normalization used by
replaceContactVariables/getContactReplacementValue) and ensure the renderer
receives both normalized keys and core keys (email, firstName, lastName) in the
matching normalized form so JSON-rendered content substitutes consistently with
the HTML path.

---

Nitpick comments:
In `@apps/web/src/app/`(dashboard)/campaigns/[campaignId]/edit/page.tsx:
- Around line 170-175: The useMemo for editorVariables currently depends on
contactBook?.variables but should depend on the whole contactBook because
contactBook is a derived render-scope value; update the dependency array of
useMemo to [contactBook] so editorVariables recomputes whenever contactBook
changes (refer to the editorVariables constant and the useMemo call that reads
contactBook?.variables).

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/contact-list.tsx:
- Around line 148-154: The code maps contactBookVariables to cells using
escapeCell but includes a redundant type assertion "as string" after the
null-coalescing expression; remove the trailing "as string" in the mapping so
the expression ((contact.properties as Record<string, string> |
undefined)?.[variable] ?? "") is passed directly to escapeCell, leaving
contactBookVariables, escapeCell, and contact.properties references unchanged.

In `@apps/web/src/server/public-api/api/contacts/create-contact-book.ts`:
- Around line 44-63: The code performs an unnecessary update when only
body.variables is present because createContactBookService already saved
variables; change the conditional from if (body.emoji || body.properties ||
body.variables) to only run when emoji or properties are explicitly provided
(e.g. if (body.emoji !== undefined || body.properties !== undefined)), and when
calling updateContactBook(contactBook.id, ...) do not include body.variables in
the payload so you avoid redundant writes; keep using the existing symbols
createContactBookService, updateContactBook, contactBook.id, body.emoji,
body.properties, and body.variables to locate and modify the logic.

In `@apps/web/src/server/public-api/api/contacts/get-contact-book.ts`:
- Around line 78-82: The return object in the c.json call redundantly reassigns
variables from contactBook (the spread already includes it); inside the
get-contact-book response, remove the explicit "variables:
contactBook.variables" property and simply return c.json({ ...contactBook,
properties: contactBook.properties as Record<string, string> }) so the spread
alone provides variables while keeping the properties cast.

In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 111-144: The function replaceContactVariables currently hard-codes
the built-in list ["email","firstname","lastname"]; replace that literal with
the existing constant BUILT_IN_CONTACT_VARIABLES to avoid drift (use the same
case-insensitive check you already do: normalize the compared value with
normalizedKey and compare against BUILT_IN_CONTACT_VARIABLES in a
case-insensitive way), keeping the rest of the logic intact
(CONTACT_VARIABLE_REGEX, allowedVariables check, and getContactReplacementValue
usage).

In `@apps/web/src/server/service/contact-book-service.ts`:
- Around line 114-122: The update call currently spreads ...data which may
include the raw variables before the normalized override; refactor the code that
calls db.contactBook.update to destructure variables out of data first (e.g.,
const { variables, ...rest } = data), then pass rest plus the normalized
variables (if defined) into db.contactBook.update so the raw variables are never
part of the spread; update the db.contactBook.update call to use the new rest
object and conditional variables: normalizedVariables to make the intent
explicit.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c9ebc8 and d6f0664.

📒 Files selected for processing (18)
  • apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql
  • apps/web/prisma/schema.prisma
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
  • apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
  • apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
  • apps/web/src/lib/zod/contact-book-schema.ts
  • apps/web/src/server/api/routers/contacts.ts
  • apps/web/src/server/public-api/api/contacts/create-contact-book.ts
  • apps/web/src/server/public-api/api/contacts/get-contact-book.ts
  • apps/web/src/server/public-api/api/contacts/get-contact-books.ts
  • apps/web/src/server/public-api/api/contacts/update-contact-book.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/contact-book-service.ts
  • apps/web/src/server/service/contact-variable-service.ts

@KMKoushik KMKoushik force-pushed the feat/contact-book-variable-registry branch from d6f0664 to 1a5b5e3 Compare February 24, 2026 21:18
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Feb 24, 2026

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: cebc799
Status: ✅  Deploy successful!
Preview URL: https://c1fb5fb7.usesend.pages.dev
Branch Preview URL: https://feat-contact-book-variable-r.usesend.pages.dev

View logs

@vercel
Copy link

vercel bot commented Feb 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
unsend-marketing Ready Ready Preview, Comment Feb 28, 2026 8:25pm

@KMKoushik KMKoushik changed the base branch from main to useSend-double-opt-in February 24, 2026 21:18
@KMKoushik KMKoushik force-pushed the feat/contact-book-variable-registry branch from a0b35d1 to d6f0664 Compare February 24, 2026 21:21
@KMKoushik KMKoushik changed the base branch from useSend-double-opt-in to main February 24, 2026 21:21
@KMKoushik KMKoushik force-pushed the feat/contact-book-variable-registry branch from d6f0664 to 1bdd005 Compare February 24, 2026 21:29
@KMKoushik KMKoushik changed the base branch from main to useSend-double-opt-in February 24, 2026 21:29
@KMKoushik KMKoushik force-pushed the useSend-double-opt-in branch from 70f0832 to 3cef365 Compare February 28, 2026 10:59
Base automatically changed from useSend-double-opt-in to main February 28, 2026 13:34
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (6)
apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx (1)

167-175: Consider stabilizing useMemo dependencies for effective memoization.

The contactBook object is derived via .find() on each render, producing a new reference even when the underlying data is unchanged. This causes the useMemo to recompute on every render, negating its benefit.

♻️ Suggested improvement

Depend on stable primitives instead of the object reference:

 const contactBook = contactBooksQuery.data?.find(
   (book) => book.id === contactBookId,
 );
+const contactBookVariables = contactBook?.variables;
 const editorVariables = useMemo(() => {
   const baseVariables = ["email", "firstName", "lastName"];
-  const registryVariables = contactBook?.variables ?? [];
+  const registryVariables = contactBookVariables ?? [];

   return Array.from(new Set([...baseVariables, ...registryVariables]));
-}, [contactBook]);
+}, [contactBookVariables]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/campaigns/[campaignId]/edit/page.tsx around
lines 167 - 175, The memo is recomputing because it depends on the transient
contactBook object; move the .find() lookup into the useMemo so the memo depends
on stable primitives instead of an object reference — i.e., compute
editorVariables inside a useMemo that depends on contactBookId and
contactBooksQuery.data (or contactBooksQuery.data ?? []), then derive
registryVariables by finding the matching book within that memo (referencing
contactBooksQuery, contactBookId, contactBook, and editorVariables to locate the
correct spot).
apps/web/src/server/service/contact-book-service.unit.test.ts (1)

48-56: Consider adding tests for variable validation and normalization.

The test suite verifies the happy path for variables, but coverage for validation failures (invalid characters, reserved names) and normalization behavior (trimming, deduplication) would strengthen confidence in the feature. These could be added to this file or a dedicated contact-variable-service.unit.test.ts.

Would you like me to generate test cases for variable validation and normalization, or open an issue to track this?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/contact-book-service.unit.test.ts` around lines
48 - 56, Add unit tests that cover variable validation failures and
normalization for the contact-variable flow within the existing
contact-book-service suite: add tests that call the variable
creation/registration path and assert that inputs with invalid characters or
reserved names cause rejection and do not call
mockDb.contactBook.create/update/findUnique, and add tests that pass inputs with
extra whitespace and duplicates to verify trimming/deduplication behavior
(assert final values passed to mockDb.contactBook.create/findMany are
normalized). Use the existing mocks (mockCheckContactBookLimit,
mockDb.contactBook.create/findMany/update/findUnique,
mockValidateDomainFromEmail) to simulate required responses and to verify no DB
writes occur on validation errors and correct normalized payloads on success;
place tests either in this file or a new contact-variable-service.unit.test.ts.
apps/web/src/server/service/campaign-service.ts (1)

97-126: Consider simplifying the Proxy-based case-insensitive lookup.

While the Proxy implementation works, it adds complexity. A simpler approach would be to normalize all keys to lowercase in the initial object creation and do a single lowercase lookup.

♻️ Optional simplification
 function createCaseInsensitiveVariableValues(
   values: Record<string, string | null | undefined>,
-) {
-  const normalizedValues = Object.entries(values).reduce(
-    (acc, [key, value]) => {
-      if (value !== undefined) {
-        acc[key] = value;
-        acc[key.toLowerCase()] = value;
-      }
-
-      return acc;
-    },
-    {} as Record<string, string | null>,
-  );
-
-  return new Proxy(normalizedValues, {
-    get(target, prop, receiver) {
-      if (typeof prop === "string") {
-        const exact = Reflect.get(target, prop, receiver);
-        if (exact !== undefined) {
-          return exact;
-        }
-
-        return Reflect.get(target, prop.toLowerCase(), receiver);
-      }
-
-      return Reflect.get(target, prop, receiver);
-    },
-  }) as Record<string, string | null>;
+): Record<string, string | null> {
+  const result: Record<string, string | null> = {};
+  for (const [key, value] of Object.entries(values)) {
+    if (value !== undefined) {
+      result[key] = value;
+      result[key.toLowerCase()] = value;
+    }
+  }
+  return result;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/campaign-service.ts` around lines 97 - 126, The
Proxy is unnecessary—simplify createCaseInsensitiveVariableValues by normalizing
keys to lowercase when building normalizedValues (e.g., for each [key,value] do
if (value !== undefined) acc[key.toLowerCase()] = value) and return that plain
object (typed as Record<string,string|null>) instead of wrapping it in a Proxy;
remove the Proxy handler and any code that relied on its behavior.
apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx (1)

151-158: Type assertion assumes all property values are strings.

The cast contact.properties as Record<string, string> may throw if a property value is not a string. While the Python SDK type shows Dict[str, str], Prisma's JSON type allows arbitrary values.

Consider adding defensive handling:

🛡️ Safer property access
       ...(contactBookVariables ?? []).map((variable) =>
         escapeCell(
-          (contact.properties as Record<string, string> | undefined)?.[
-            variable
-          ] ?? "",
+          String(
+            (contact.properties as Record<string, unknown> | undefined)?.[
+              variable
+            ] ?? ""
+          ),
         ),
       ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/contact-list.tsx
around lines 151 - 158, The code currently force-casts contact.properties to
Record<string, string> which can break if a property value isn't a string;
update the mapping that calls escapeCell so it reads the value without asserting
its type, then coerce it to a safe string (e.g., if typeof value === 'string'
use it, if null/undefined use "", otherwise JSON.stringify(value) or
String(value)) before passing to escapeCell; target the expression around
contact.properties, the variable map callback, and the escapeCell call to
implement this defensive conversion.
apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx (2)

112-119: Header row detection could skip valid data rows.

If a data row's first email starts with "email" (e.g., "email123@example.com"), it would be skipped. Consider making the check stricter by requiring an exact match after case normalization.

🛡️ Stricter header detection
     const firstPart = parts[0]?.toLowerCase();
     if (
-      firstPart === "email" ||
-      firstPart === "e-mail" ||
-      firstPart === "email address"
+      (firstPart === "email" ||
+       firstPart === "e-mail" ||
+       firstPart === "email address") &&
+      !validateEmail(parts[0] ?? "")
     ) {
       return null;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
around lines 112 - 119, The header-detection logic using firstPart =
parts[0]?.toLowerCase() can incorrectly skip rows whose first cell begins with
the word "email" (e.g., "email123@example.com"); change the check in
bulk-upload-contacts.tsx so it only treats a row as a header when the entire
first cell equals (after trim+lowercase) "email", "e-mail", or "email address"
rather than when it merely starts with those strings — update the condition that
references firstPart and parts to use full-string equality (trimmed and
lowercased) for the header match.

215-221: Properties object is always empty in the non-header path.

In the fallback positional parsing (lines 196-221), properties is initialized but never populated, so the check Object.keys(properties).length > 0 always evaluates to false and returns undefined. This is harmless but slightly inconsistent with the header-based path.

♻️ Cleaner approach: remove unused properties check
     return {
       email,
       firstName,
       lastName,
-      properties: Object.keys(properties).length > 0 ? properties : undefined,
       subscribed,
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
around lines 215 - 221, The fallback positional parsing returns an object with
keys email, firstName, lastName, properties, subscribed but never populates
properties so the conditional Object.keys(properties).length > 0 ? properties :
undefined always yields undefined; remove that conditional and return properties
directly (i.e., keep the properties key as-is) so the non-header path is
consistent with the header-based path and no special emptiness check is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/src/app/`(dashboard)/campaigns/[campaignId]/edit/page.tsx:
- Around line 167-175: The memo is recomputing because it depends on the
transient contactBook object; move the .find() lookup into the useMemo so the
memo depends on stable primitives instead of an object reference — i.e., compute
editorVariables inside a useMemo that depends on contactBookId and
contactBooksQuery.data (or contactBooksQuery.data ?? []), then derive
registryVariables by finding the matching book within that memo (referencing
contactBooksQuery, contactBookId, contactBook, and editorVariables to locate the
correct spot).

In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx:
- Around line 112-119: The header-detection logic using firstPart =
parts[0]?.toLowerCase() can incorrectly skip rows whose first cell begins with
the word "email" (e.g., "email123@example.com"); change the check in
bulk-upload-contacts.tsx so it only treats a row as a header when the entire
first cell equals (after trim+lowercase) "email", "e-mail", or "email address"
rather than when it merely starts with those strings — update the condition that
references firstPart and parts to use full-string equality (trimmed and
lowercased) for the header match.
- Around line 215-221: The fallback positional parsing returns an object with
keys email, firstName, lastName, properties, subscribed but never populates
properties so the conditional Object.keys(properties).length > 0 ? properties :
undefined always yields undefined; remove that conditional and return properties
directly (i.e., keep the properties key as-is) so the non-header path is
consistent with the header-based path and no special emptiness check is used.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/contact-list.tsx:
- Around line 151-158: The code currently force-casts contact.properties to
Record<string, string> which can break if a property value isn't a string;
update the mapping that calls escapeCell so it reads the value without asserting
its type, then coerce it to a safe string (e.g., if typeof value === 'string'
use it, if null/undefined use "", otherwise JSON.stringify(value) or
String(value)) before passing to escapeCell; target the expression around
contact.properties, the variable map callback, and the escapeCell call to
implement this defensive conversion.

In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 97-126: The Proxy is unnecessary—simplify
createCaseInsensitiveVariableValues by normalizing keys to lowercase when
building normalizedValues (e.g., for each [key,value] do if (value !==
undefined) acc[key.toLowerCase()] = value) and return that plain object (typed
as Record<string,string|null>) instead of wrapping it in a Proxy; remove the
Proxy handler and any code that relied on its behavior.

In `@apps/web/src/server/service/contact-book-service.unit.test.ts`:
- Around line 48-56: Add unit tests that cover variable validation failures and
normalization for the contact-variable flow within the existing
contact-book-service suite: add tests that call the variable
creation/registration path and assert that inputs with invalid characters or
reserved names cause rejection and do not call
mockDb.contactBook.create/update/findUnique, and add tests that pass inputs with
extra whitespace and duplicates to verify trimming/deduplication behavior
(assert final values passed to mockDb.contactBook.create/findMany are
normalized). Use the existing mocks (mockCheckContactBookLimit,
mockDb.contactBook.create/findMany/update/findUnique,
mockValidateDomainFromEmail) to simulate required responses and to verify no DB
writes occur on validation errors and correct normalized payloads on success;
place tests either in this file or a new contact-variable-service.unit.test.ts.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d6f0664 and 2463fd9.

📒 Files selected for processing (19)
  • apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql
  • apps/web/prisma/schema.prisma
  • apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/bulk-upload-contacts.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/edit-contact.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
  • apps/web/src/app/(dashboard)/contacts/add-contact-book.tsx
  • apps/web/src/app/(dashboard)/contacts/edit-contact-book.tsx
  • apps/web/src/lib/zod/contact-book-schema.ts
  • apps/web/src/server/api/routers/contacts.ts
  • apps/web/src/server/public-api/api/contacts/create-contact-book.ts
  • apps/web/src/server/public-api/api/contacts/get-contact-book.ts
  • apps/web/src/server/public-api/api/contacts/get-contact-books.ts
  • apps/web/src/server/public-api/api/contacts/update-contact-book.ts
  • apps/web/src/server/service/campaign-service.ts
  • apps/web/src/server/service/contact-book-service.ts
  • apps/web/src/server/service/contact-book-service.unit.test.ts
  • apps/web/src/server/service/contact-variable-service.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
  • apps/web/prisma/schema.prisma
  • apps/web/src/server/public-api/api/contacts/get-contact-book.ts
  • apps/web/src/lib/zod/contact-book-schema.ts
  • apps/web/prisma/migrations/20260224120000_add_contact_book_variables/migration.sql

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant