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:
@@ -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: "📙",
|
||||
|
||||
Reference in New Issue
Block a user