mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-03 11:30:50 -05:00
740 lines
22 KiB
TypeScript
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;
|
|
};
|