Files
formbricks/apps/web/modules/ee/contacts/lib/attributes.ts
T
Anshuman Pandey f7e8bc1630 feat: attributes data types (#7246)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-13 08:55:06 +00:00

382 lines
14 KiB
TypeScript

import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributesInput, ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
import {
formatValidationError,
validateAndParseAttributeValue,
} from "@/modules/ee/contacts/lib/validate-attribute-type";
/**
* Structured message with code and params for i18n support.
* Used for both UI-facing messages (translated) and API/SDK responses (formatted to English).
*/
export interface TAttributeUpdateMessage {
code: string;
params: Record<string, string>;
}
/**
* English templates for formatting structured messages to human-readable strings.
* Used by SDK/API paths that return English responses.
*/
const MESSAGE_TEMPLATES: Record<string, string> = {
email_or_userid_required: "Either email or userId is required. The existing values were preserved.",
attribute_type_validation_error: "{error} (attribute '{key}' has dataType: {dataType})",
email_already_exists: "The email already exists for this environment and was not updated.",
userid_already_exists: "The userId already exists for this environment and was not updated.",
invalid_attribute_keys:
"Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
attribute_limit_exceeded:
"Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
new_attribute_created: "Created new attribute '{key}' with type '{dataType}'",
};
/**
* Formats a structured message to a human-readable English string.
* Used for API/SDK responses.
*/
export const formatAttributeMessage = (msg: TAttributeUpdateMessage): string => {
let template = MESSAGE_TEMPLATES[msg.code] || msg.code;
for (const [key, value] of Object.entries(msg.params)) {
template = template.replaceAll(`{${key}}`, value);
}
return template;
};
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
const deleteAttributes = async (
contactId: string,
currentAttributes: TContactAttributesInput,
submittedAttributes: TContactAttributesInput,
contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => {
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Determine which attributes should be deleted (exist in DB but not in payload, and not default attributes)
const submittedKeys = new Set(Object.keys(submittedAttributes));
const currentKeys = new Set(Object.keys(currentAttributes));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
// Get attribute key IDs for deletion
const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id)
.filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({
where: {
contactId,
attributeKeyId: {
in: attributeKeyIdsToDelete,
},
},
});
}
return {
success: true,
};
};
/**
* Updates or creates contact attributes.
*
* @param contactId - The ID of the contact to update
* @param userId - The user ID of the contact
* @param environmentId - The environment ID
* @param contactAttributesParam - The attributes to update/create
* @param deleteRemovedAttributes - When true, deletes attributes that exist in DB but are not in the payload.
* Use this for UI forms where all attributes are submitted. Default is false (merge behavior) for API calls.
*/
export const updateAttributes = async (
contactId: string,
userId: string,
environmentId: string,
contactAttributesParam: TContactAttributesInput,
deleteRemovedAttributes: boolean = false
): Promise<{
success: boolean;
messages?: TAttributeUpdateMessage[];
errors?: TAttributeUpdateMessage[];
ignoreEmailAttribute?: boolean;
ignoreUserIdAttribute?: boolean;
}> => {
validateInputs(
[contactId, ZId],
[userId, ZString],
[environmentId, ZId],
[contactAttributesParam, ZContactAttributesInput]
);
let ignoreEmailAttribute = false;
let ignoreUserIdAttribute = false;
const messages: TAttributeUpdateMessage[] = [];
const errors: TAttributeUpdateMessage[] = [];
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
const emailValue =
contactAttributesParam.email === null || contactAttributesParam.email === undefined
? null
: String(contactAttributesParam.email);
const userIdValue =
contactAttributesParam.userId === null || contactAttributesParam.userId === undefined
? null
: String(contactAttributesParam.userId);
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
emailValue ? hasEmailAttribute(emailValue, environmentId, contactId) : Promise.resolve(null),
userIdValue ? hasUserIdAttribute(userIdValue, environmentId, contactId) : Promise.resolve(null),
]);
// Process email and userId existence early
const emailExists = !!existingEmailAttribute;
const userIdExists = !!existingUserIdAttribute;
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...contactAttributesParam };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes
const emailWasSubmitted = "email" in contactAttributesParam;
const userIdWasSubmitted = "userId" in contactAttributesParam;
const submittedEmail = emailWasSubmitted ? emailValue?.trim() || "" : null;
const submittedUserId = userIdWasSubmitted ? userIdValue?.trim() || "" : null;
const currentEmail = currentAttributes.email || "";
const currentUserId = currentAttributes.userId || "";
// Calculate final values:
// - If not submitted, keep current value
// - If submitted but duplicate exists, keep current value
// - If submitted and no duplicate, use submitted value
const getFinalEmail = (): string => {
if (submittedEmail === null) return currentEmail;
if (emailExists) return currentEmail;
return submittedEmail;
};
const getFinalUserId = (): string => {
if (submittedUserId === null) return currentUserId;
if (userIdExists) return currentUserId;
return submittedUserId;
};
const finalEmail = getFinalEmail();
const finalUserId = getFinalUserId();
// Ensure at least one of email or userId will have a value after update
if (!finalEmail && !finalUserId) {
// If both would be empty, preserve the current values
if (currentEmail) {
contactAttributes.email = currentEmail;
}
if (currentUserId) {
contactAttributes.userId = currentUserId;
}
messages.push({ code: "email_or_userid_required", params: {} });
}
if (emailExists) {
const { email: _email, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreEmailAttribute = true;
}
if (userIdExists) {
const { userId: _userId, ...rest } = contactAttributes;
contactAttributes = rest;
ignoreUserIdAttribute = true;
}
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes)
if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
}
// Create lookup map for attribute keys
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Separate existing and new attributes, validating types for existing attributes
const existingAttributes: {
key: string;
attributeKeyId: string;
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
}[] = [];
const newAttributes: { key: string; value: string | number }[] = [];
for (const [key, value] of Object.entries(contactAttributes)) {
const attributeKey = contactAttributeKeyMap.get(key);
if (attributeKey) {
// Existing attribute - validate type and prepare columns
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
if (validationResult.valid) {
existingAttributes.push({
key,
attributeKeyId: attributeKey.id,
columns: validationResult.parsedValue,
});
} else {
// Type mismatch - add structured error
messages.push({
code: "attribute_type_validation_error",
params: {
key,
dataType: attributeKey.dataType,
error: formatValidationError(validationResult.error),
},
});
}
} else {
// New attribute - will detect type on creation
newAttributes.push({ key, value });
}
}
if (emailExists) {
messages.push({ code: "email_already_exists", params: {} });
}
if (userIdExists) {
messages.push({ code: "userid_already_exists", params: {} });
}
// Update all existing attributes with typed column values
if (existingAttributes.length > 0) {
await prisma.$transaction(
existingAttributes.map(({ attributeKeyId, columns }) =>
prisma.contactAttribute.upsert({
where: {
contactId_attributeKeyId: {
contactId,
attributeKeyId,
},
},
update: {
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
create: {
contactId,
attributeKeyId,
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
})
)
);
}
// Then, try to create new attributes if any exist
if (newAttributes.length > 0) {
// Validate that new attribute keys are safe identifiers
const validNewAttributes: typeof newAttributes = [];
const invalidKeys: string[] = [];
for (const attr of newAttributes) {
if (isSafeIdentifier(attr.key)) {
validNewAttributes.push(attr);
} else {
invalidKeys.push(attr.key);
}
}
// Add error message for invalid keys
if (invalidKeys.length > 0) {
errors.push({
code: "invalid_attribute_keys",
params: { keys: invalidKeys.join(", ") },
});
logger.warn(
{ environmentId, invalidKeys },
"SDK tried to create attributes with invalid keys - skipping"
);
}
if (validNewAttributes.length > 0) {
const totalAttributeClassesLength = contactAttributeKeys.length + validNewAttributes.length;
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
// Add warning to details about skipped attributes
messages.push({
code: "attribute_limit_exceeded",
params: {
count: validNewAttributes.length.toString(),
limit: MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT.toString(),
},
});
} else {
// Prepare new attributes with SDK-specific type detection
const preparedNewAttributes = validNewAttributes.map(({ key, value }) => {
const { dataType, columns } = prepareNewSDKAttributeForStorage(value);
return { key, dataType, columns };
});
// Log new attribute creation with their types
for (const { key, dataType } of preparedNewAttributes) {
logger.info({ environmentId, attributeKey: key, dataType }, "Created new contact attribute");
messages.push({ code: "new_attribute_created", params: { key, dataType } });
}
// Create new attributes since we're under the limit
await prisma.$transaction(
preparedNewAttributes.map(({ key, dataType, columns }) =>
prisma.contactAttributeKey.create({
data: {
key,
name: formatSnakeCaseToTitleCase(key),
type: "custom",
dataType,
environment: { connect: { id: environmentId } },
attributes: {
create: {
contactId,
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
},
},
})
)
);
}
}
}
return {
success: true,
messages: messages.length > 0 ? messages : undefined,
errors: errors.length > 0 ? errors : undefined,
ignoreEmailAttribute,
ignoreUserIdAttribute,
};
};