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

* feat: add contact-book variable registry for campaign personalization

* test: include contact-book variables default in service expectation

* fix: address personalization review issues

* fix text

* fix: normalize contact variable access across contact flows

* stuff

* fix
This commit is contained in:
KM Koushik
2026-03-08 00:03:58 +11:00
committed by GitHub
parent d97e445ea0
commit 62e0a1db88
29 changed files with 1564 additions and 406 deletions
+88
View File
@@ -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<string, unknown> | null,
allowedVariables: string[] = [],
) {
const normalizedProperties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(properties ?? {})) {
const canonicalKey = getCanonicalContactVariableName(key, allowedVariables);
normalizedProperties[canonicalKey ?? key] = value;
}
return normalizedProperties;
}
export function getContactPropertyValue(
properties: Record<string, unknown> | 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<string, unknown> | null,
incomingProperties?: Record<string, unknown> | null,
allowedVariables: string[] = [],
) {
return {
...normalizeContactProperties(existingProperties, allowedVariables),
...normalizeContactProperties(incomingProperties, allowedVariables),
};
}
export function replaceContactVariableValues(
existingProperties: Record<string, unknown> | null | undefined,
variableValues: Record<string, unknown>,
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,
);
}
@@ -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",
});
});
});
@@ -14,6 +14,10 @@ export const ContactBookSchema = z.object({
description: "Custom properties for the contact book",
example: { customField1: "value1" },
}),
variables: z.array(z.string()).openapi({
description: "Allowed personalization variables for contacts in this book",
example: ["registrationCode", "company"],
}),
emoji: z.string().openapi({
description: "The emoji associated with the contact book",
example: "📙",