mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-24 11:39:31 -05:00
fff0a7f052
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
253 lines
8.8 KiB
TypeScript
253 lines
8.8 KiB
TypeScript
import { prisma } from "@formbricks/database";
|
|
import { ZId, ZString } from "@formbricks/types/common";
|
|
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
|
|
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
|
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
|
import { validateInputs } from "@/lib/utils/validate";
|
|
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
|
import {
|
|
getContactAttributes,
|
|
hasEmailAttribute,
|
|
hasUserIdAttribute,
|
|
} from "@/modules/ee/contacts/lib/contact-attributes";
|
|
|
|
// 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: TContactAttributes,
|
|
submittedAttributes: TContactAttributes,
|
|
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: TContactAttributes,
|
|
deleteRemovedAttributes: boolean = false
|
|
): Promise<{
|
|
success: boolean;
|
|
messages?: string[];
|
|
ignoreEmailAttribute?: boolean;
|
|
ignoreUserIdAttribute?: boolean;
|
|
}> => {
|
|
validateInputs(
|
|
[contactId, ZId],
|
|
[userId, ZString],
|
|
[environmentId, ZId],
|
|
[contactAttributesParam, ZContactAttributes]
|
|
);
|
|
|
|
let ignoreEmailAttribute = false;
|
|
let ignoreUserIdAttribute = false;
|
|
const messages: string[] = [];
|
|
|
|
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
|
|
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
|
|
await Promise.all([
|
|
getContactAttributes(contactId),
|
|
getContactAttributeKeys(environmentId),
|
|
contactAttributesParam.email
|
|
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
|
: Promise.resolve(null),
|
|
contactAttributesParam.userId
|
|
? hasUserIdAttribute(contactAttributesParam.userId, 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 ? contactAttributes.email?.trim() || "" : null;
|
|
const submittedUserId = userIdWasSubmitted ? contactAttributes.userId?.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("Either email or userId is required. The existing values were preserved.");
|
|
}
|
|
|
|
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 in a single pass
|
|
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
|
|
(acc, [key, value]) => {
|
|
const attributeKey = contactAttributeKeyMap.get(key);
|
|
if (attributeKey) {
|
|
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
|
} else {
|
|
acc.newAttributes.push({ key, value });
|
|
}
|
|
return acc;
|
|
},
|
|
{ existingAttributes: [], newAttributes: [] } as {
|
|
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
|
newAttributes: { key: string; value: string }[];
|
|
}
|
|
);
|
|
|
|
if (emailExists) {
|
|
messages.push("The email already exists for this environment and was not updated.");
|
|
}
|
|
|
|
if (userIdExists) {
|
|
messages.push("The userId already exists for this environment and was not updated.");
|
|
}
|
|
|
|
// Update all existing attributes
|
|
if (existingAttributes.length > 0) {
|
|
await prisma.$transaction(
|
|
existingAttributes.map(({ attributeKeyId, value }) =>
|
|
prisma.contactAttribute.upsert({
|
|
where: {
|
|
contactId_attributeKeyId: {
|
|
contactId,
|
|
attributeKeyId,
|
|
},
|
|
},
|
|
update: { value },
|
|
create: {
|
|
contactId,
|
|
attributeKeyId,
|
|
value,
|
|
},
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
// Then, try to create new attributes if any exist
|
|
if (newAttributes.length > 0) {
|
|
const totalAttributeClassesLength = contactAttributeKeys.length + newAttributes.length;
|
|
|
|
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
|
// Add warning to details about skipped attributes
|
|
messages.push(
|
|
`Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
|
|
);
|
|
} else {
|
|
// Create new attributes since we're under the limit
|
|
await prisma.$transaction(
|
|
newAttributes.map(({ key, value }) =>
|
|
prisma.contactAttributeKey.create({
|
|
data: {
|
|
key,
|
|
type: "custom",
|
|
environment: { connect: { id: environmentId } },
|
|
attributes: {
|
|
create: { contactId, value },
|
|
},
|
|
},
|
|
})
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
messages: messages.length > 0 ? messages : undefined,
|
|
ignoreEmailAttribute,
|
|
ignoreUserIdAttribute,
|
|
};
|
|
};
|