Files
formbricks/apps/web/modules/ee/contacts/lib/contacts.ts
T

740 lines
22 KiB
TypeScript

import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { validateInputs } from "@/lib/utils/validate";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import {
TContact,
TContactWithAttributes,
ZContactCSVAttributeMap,
ZContactCSVDuplicateAction,
ZContactCSVUploadResponse,
} from "../types/contact";
import { transformPrismaContact } from "./utils";
export const getContactsInSegment = reactCache(async (segmentId: string) => {
try {
const segment = await getSegment(segmentId);
if (!segment) {
return null;
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return null;
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const requiredAttributes = ["userId", "firstName", "lastName", "email"];
const contacts = await prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: requiredAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
attributes,
};
});
return contactsWithAttributes;
} catch (error) {
logger.error(error, "Failed to get contacts in segment");
return null;
}
});
const selectContact = {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
attributes: {
select: {
value: true,
valueNumber: true,
valueDate: true,
attributeKey: {
select: {
key: true,
name: true,
dataType: true,
},
},
},
},
} satisfies Prisma.ContactSelect;
export const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
const whereClause: Prisma.ContactWhereInput = { environmentId };
if (search) {
whereClause.OR = [
{
attributes: {
some: {
value: {
contains: search,
mode: "insensitive",
},
},
},
},
{
id: {
contains: search,
mode: "insensitive",
},
},
];
}
return whereClause;
};
export const getContacts = reactCache(
async (environmentId: string, offset?: number, searchValue?: string): Promise<TContactWithAttributes[]> => {
validateInputs([environmentId, ZId], [offset, ZOptionalNumber], [searchValue, ZOptionalString]);
try {
const contacts = await prisma.contact.findMany({
where: buildContactWhereClause(environmentId, searchValue),
select: selectContact,
take: ITEMS_PER_PAGE,
skip: offset,
orderBy: {
createdAt: "desc",
},
});
return contacts.map((contact) => transformPrismaContact(contact));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
export const getContact = reactCache(async (contactId: string): Promise<TContact | null> => {
validateInputs([contactId, ZId]);
try {
return await prisma.contact.findUnique({
where: {
id: contactId,
},
select: selectContact,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
export const deleteContact = async (contactId: string): Promise<TContact | null> => {
validateInputs([contactId, ZId]);
try {
const contact = await prisma.contact.delete({
where: {
id: contactId,
},
select: selectContact,
});
return contact;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
// Shared include clause for contact queries
const contactAttributesInclude = {
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
} satisfies Prisma.ContactInclude;
// Helper to create attribute objects for Prisma create operations with typed columns
const createAttributeConnections = (
record: Record<string, string>,
environmentId: string,
attributeTypeMap: Map<string, TAttributeTypeInfo>
) =>
Object.entries(record).map(([key, value]) => {
const dataType = attributeTypeMap.get(key)?.dataType ?? "string";
const columns = prepareAttributeColumnsForStorage(value, dataType);
return {
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
};
});
// Helper to handle userId conflicts when updating/overwriting contacts
const resolveUserIdConflict = (
mappedRecord: Record<string, string>,
existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] },
existingUserIds: { value: string; contactId: string }[]
): Record<string, string> => {
const existingUserId = existingUserIds.find(
(attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id
);
if (!existingUserId) {
return { ...mappedRecord };
}
const { userId: _userId, ...rest } = mappedRecord;
const existingContactUserId = existingContact.attributes.find(
(attr) => attr.attributeKey.key === "userId"
)?.value;
return {
...rest,
...(existingContactUserId && { userId: existingContactUserId }),
};
};
/**
* Extracts unique emails, userIds, and attribute keys from CSV data
*/
const extractCsvMetadata = (
csvData: Record<string, string>[]
): {
csvEmails: string[];
csvUserIds: string[];
csvKeys: Set<string>;
attributeValuesByKey: Map<string, string[]>;
} => {
const csvEmails = Array.from(new Set(csvData.map((r) => r.email).filter(Boolean)));
const csvUserIds = Array.from(new Set(csvData.map((r) => r.userId).filter(Boolean)));
const csvKeys = new Set<string>();
const attributeValuesByKey = new Map<string, string[]>();
for (const record of csvData) {
for (const [key, value] of Object.entries(record)) {
csvKeys.add(key);
const existingValues = attributeValuesByKey.get(key) ?? [];
if (value && value.trim() !== "") {
existingValues.push(value);
}
attributeValuesByKey.set(key, existingValues);
}
}
return { csvEmails, csvUserIds, csvKeys, attributeValuesByKey };
};
/**
* Builds a map of attribute keys to their detected/existing data types
*/
type TAttributeTypeInfo = {
dataType: TContactAttributeDataType;
isExisting: boolean; // true = from DB, false = newly detected
};
const buildAttributeTypeMap = (
attributeValuesByKey: Map<string, string[]>,
existingAttributeKeys: { key: string; dataType: TContactAttributeDataType }[],
lowercaseToActualKeyMap: Map<string, string>
): Map<string, TAttributeTypeInfo> => {
const attributeTypeMap = new Map<string, TAttributeTypeInfo>();
for (const [key, values] of attributeValuesByKey) {
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
const existingKey = actualKey ? existingAttributeKeys.find((ak) => ak.key === actualKey) : null;
if (existingKey) {
attributeTypeMap.set(key, { dataType: existingKey.dataType, isExisting: true });
} else {
const firstValue = values.find((v) => v !== "");
const detectedType = firstValue ? detectAttributeDataType(firstValue) : "string";
attributeTypeMap.set(key, { dataType: detectedType, isExisting: false });
}
}
return attributeTypeMap;
};
/**
* Finds invalid values for a given attribute type
*/
const findInvalidValuesForType = (values: string[], dataType: TContactAttributeDataType): string[] => {
const invalidValues: string[] = [];
for (const value of values) {
const columns = prepareAttributeColumnsForStorage(value, dataType);
const parseFailed =
(dataType === "number" && columns.valueNumber === null) ||
(dataType === "date" && columns.valueDate === null);
if (parseFailed) {
invalidValues.push(value);
}
}
return invalidValues;
};
interface TCsvAttributeValidationError {
key: string;
dataType: TContactAttributeDataType;
invalidValues: string[];
}
/**
* Validates attribute values against their types.
* - For EXISTING typed attributes: returns validation errors
* - For NEW attributes: downgrades to string if values are inconsistent
*/
const validateAndAdjustCsvAttributeTypes = (
attributeTypeMap: Map<string, TAttributeTypeInfo>,
attributeValuesByKey: Map<string, string[]>
): TCsvAttributeValidationError[] => {
const typeValidationWarnings: string[] = [];
const validationErrors: TCsvAttributeValidationError[] = [];
for (const [key, typeInfo] of attributeTypeMap) {
if (typeInfo.dataType === "string") continue;
const values = attributeValuesByKey.get(key) || [];
const invalidValues = findInvalidValuesForType(values, typeInfo.dataType);
if (invalidValues.length === 0) continue;
if (typeInfo.isExisting) {
// EXISTING typed attribute: collect error
validationErrors.push({ key, dataType: typeInfo.dataType, invalidValues });
continue;
}
// NEW attribute: downgrade to string
attributeTypeMap.set(key, { dataType: "string", isExisting: false });
typeValidationWarnings.push(
`Attribute "${key}" has mixed or invalid values for type "${typeInfo.dataType}", treating as string type`
);
}
if (typeValidationWarnings.length > 0) {
logger.warn({ warnings: typeValidationWarnings }, "Type validation warnings during CSV upload");
}
return validationErrors;
};
/**
* Creates missing attribute keys in the database
*/
const createMissingAttributeKeys = async (
csvKeys: Set<string>,
lowercaseToActualKeyMap: Map<string, string>,
attributeKeyMap: Map<string, string>,
attributeTypeMap: Map<string, TAttributeTypeInfo>,
environmentId: string
): Promise<void> => {
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
if (missingKeys.length === 0) return;
// Validate that all missing keys are safe identifiers
const invalidKeys = missingKeys.filter((key) => !isSafeIdentifier(key));
if (invalidKeys.length > 0) {
throw new ValidationError(
`Invalid attribute key(s): ${invalidKeys.join(", ")}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.`
);
}
// Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname"
const uniqueMissingKeys = new Map<string, string>();
for (const key of missingKeys) {
const lowerKey = key.toLowerCase();
if (!uniqueMissingKeys.has(lowerKey)) {
uniqueMissingKeys.set(lowerKey, key);
}
}
await prisma.contactAttributeKey.createMany({
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: formatSnakeCaseToTitleCase(key),
dataType: attributeTypeMap.get(key)?.dataType ?? "string",
environmentId,
})),
skipDuplicates: true,
});
// Fetch and update the maps with new keys
const newAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true, dataType: true },
});
for (const attrKey of newAttributeKeys) {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
}
};
type TExistingContactFromCsv = {
id: string;
attributes: { attributeKey: { key: string; id: string }; value: string }[];
};
type TExistingUserId = { value: string; contactId: string };
type TCsvProcessingContext = {
lowercaseToActualKeyMap: Map<string, string>;
emailToContactMap: Map<string, TExistingContactFromCsv>;
existingUserIds: TExistingUserId[];
attributeKeyMap: Map<string, string>;
attributeTypeMap: Map<string, TAttributeTypeInfo>;
duplicateContactsAction: "skip" | "update" | "overwrite";
environmentId: string;
};
/**
* Processes a single CSV record to create or update a contact
*/
const processCsvRecord = async (
record: Record<string, string>,
ctx: TCsvProcessingContext
): Promise<TContact | null> => {
const {
lowercaseToActualKeyMap,
emailToContactMap,
existingUserIds,
attributeKeyMap,
attributeTypeMap,
duplicateContactsAction,
environmentId,
} = ctx;
// Map CSV keys to actual DB keys (case-insensitive matching)
const mappedRecord: Record<string, string> = {};
for (const [key, value] of Object.entries(record)) {
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
if (!actualKey) {
throw new ValidationError(`Attribute key "${key}" not found in attribute key map`);
}
mappedRecord[actualKey] = value;
}
if (!mappedRecord.email) {
throw new ValidationError("Email is required for all contacts");
}
const existingContact = emailToContactMap.get(mappedRecord.email);
if (!existingContact) {
// Create new contact
return prisma.contact.create({
data: {
environmentId,
attributes: {
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
},
},
include: contactAttributesInclude,
});
}
// Handle duplicate based on action
return handleDuplicateContact(
mappedRecord,
existingContact,
existingUserIds,
attributeKeyMap,
attributeTypeMap,
duplicateContactsAction,
environmentId
);
};
/**
* Handles duplicate contact based on the specified action
*/
const handleDuplicateContact = async (
mappedRecord: Record<string, string>,
existingContact: TExistingContactFromCsv,
existingUserIds: TExistingUserId[],
attributeKeyMap: Map<string, string>,
attributeTypeMap: Map<string, TAttributeTypeInfo>,
duplicateContactsAction: "skip" | "update" | "overwrite",
environmentId: string
): Promise<TContact | null> => {
if (duplicateContactsAction === "skip") {
return null;
}
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
if (duplicateContactsAction === "update") {
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => {
const dataType = attributeTypeMap.get(key)?.dataType ?? "string";
const columns = prepareAttributeColumnsForStorage(value, dataType);
return {
where: {
contactId_attributeKeyId: {
contactId: existingContact.id,
attributeKeyId: attributeKeyMap.get(key),
},
},
update: {
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
create: {
attributeKeyId: attributeKeyMap.get(key),
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
};
});
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
// @ts-expect-error - Prisma types don't fully support upsert array
upsert: attributesToUpsert,
},
},
include: contactAttributesInclude,
});
}
// duplicateContactsAction = "overwrite" here
await prisma.contactAttribute.deleteMany({
where: { contactId: existingContact.id },
});
return prisma.contact.update({
where: { id: existingContact.id },
data: {
attributes: {
create: createAttributeConnections(recordToProcess, environmentId, attributeTypeMap),
},
},
include: contactAttributesInclude,
});
};
export type TCreateContactsFromCSVResult =
| { contacts: TContact[] }
| { validationErrors: TCsvAttributeValidationError[] };
export const createContactsFromCSV = async (
csvData: Record<string, string>[],
environmentId: string,
duplicateContactsAction: "skip" | "update" | "overwrite",
attributeMap: Record<string, string>
): Promise<TCreateContactsFromCSVResult> => {
validateInputs(
[csvData, ZContactCSVUploadResponse],
[environmentId, ZId],
[duplicateContactsAction, ZContactCSVDuplicateAction],
[attributeMap, ZContactCSVAttributeMap]
);
try {
// Step 1: Extract metadata from CSV data
const { csvEmails, csvUserIds, csvKeys, attributeValuesByKey } = extractCsvMetadata(csvData);
// Step 2: Fetch existing data from database
const [existingContactsByEmail, existingUserIds, existingAttributeKeys] = await Promise.all([
prisma.contact.findMany({
where: {
environmentId,
attributes: { some: { attributeKey: { key: "email" }, value: { in: csvEmails } } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true, id: true } }, value: true } },
},
}),
prisma.contactAttribute.findMany({
where: { attributeKey: { key: "userId", environmentId }, value: { in: csvUserIds } },
select: { value: true, contactId: true },
}),
prisma.contactAttributeKey.findMany({
where: { environmentId },
select: { key: true, id: true, dataType: true },
}),
]);
// Step 3: Build lookup maps
const emailToContactMap = new Map<string, TExistingContactFromCsv>();
for (const contact of existingContactsByEmail) {
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === "email");
if (emailAttr) {
emailToContactMap.set(emailAttr.value, contact);
}
}
const attributeKeyMap = new Map<string, string>();
const lowercaseToActualKeyMap = new Map<string, string>();
for (const attrKey of existingAttributeKeys) {
attributeKeyMap.set(attrKey.key, attrKey.id);
lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key);
}
// Step 4: Detect and validate attribute types
const attributeTypeMap = buildAttributeTypeMap(
attributeValuesByKey,
existingAttributeKeys,
lowercaseToActualKeyMap
);
const validationErrors = validateAndAdjustCsvAttributeTypes(attributeTypeMap, attributeValuesByKey);
if (validationErrors.length > 0) {
return { validationErrors };
}
// Step 5: Create missing attribute keys
await createMissingAttributeKeys(
csvKeys,
lowercaseToActualKeyMap,
attributeKeyMap,
attributeTypeMap,
environmentId
);
// Step 6: Process each CSV record
const processingContext: TCsvProcessingContext = {
lowercaseToActualKeyMap,
emailToContactMap,
existingUserIds,
attributeKeyMap,
attributeTypeMap,
duplicateContactsAction,
environmentId,
};
const CHUNK_SIZE = 50;
const allResults: (TContact | null)[] = [];
for (let i = 0; i < csvData.length; i += CHUNK_SIZE) {
const chunk = csvData.slice(i, i + CHUNK_SIZE);
const chunkResults = await Promise.all(
chunk.map((record) => processCsvRecord(record, processingContext))
);
allResults.push(...chunkResults);
}
return { contacts: allResults.filter((contact): contact is TContact => contact !== null) };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
const contactsResult = await getContactsInSegment(segmentId);
if (!contactsResult) {
return null;
}
// Generate survey links for each contact
const contactLinks = await Promise.all(
contactsResult.map(async (contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = await getContactSurveyLink(contactId, surveyId, expirationDays);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expirationDays,
};
})
);
const filteredContactLinks = contactLinks.filter(Boolean);
return filteredContactLinks;
};