fix: adds logic for returning the duplicate emails and userIds

This commit is contained in:
pandeymangg
2025-03-25 10:54:33 +05:30
parent d873e5b759
commit cea7139b40
13 changed files with 110 additions and 125 deletions

View File

@@ -19,6 +19,11 @@ export const isManagementApiRoute = (url: string): boolean => {
return regex.test(url);
};
export const isContactsBulkApiRoute = (url: string): boolean => {
const regex = /^\/api\/v2\/contacts\/bulk$/;
return regex.test(url);
};
export const isShareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
return regex.test(url);

View File

@@ -10,6 +10,7 @@ import {
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isContactsBulkApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
@@ -32,7 +33,12 @@ const enforceHttps = (request: NextRequest): Response | null => {
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
const apiError: ApiErrorResponseV2 = {
type: "forbidden",
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
details: [
{
field: "",
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
},
],
};
logApiError(request, apiError);
return NextResponse.json(apiError, { status: 403 });
@@ -95,7 +101,7 @@ export const middleware = async (originalRequest: NextRequest) => {
});
// Enforce HTTPS for management endpoints
if (isManagementApiRoute(request.nextUrl.pathname)) {
if (isManagementApiRoute(request.nextUrl.pathname) || isContactsBulkApiRoute(request.nextUrl.pathname)) {
const httpsResponse = enforceHttps(request);
if (httpsResponse) return httpsResponse;
}
@@ -147,5 +153,6 @@ export const config = {
"/auth/forgot-password",
"/api/v1/management/:path*",
"/api/v2/management/:path*",
"/api/v2/contacts/bulk",
],
};

View File

@@ -1,6 +1,6 @@
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ZodError } from "zod";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
@@ -34,11 +34,16 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
}
};
export const formatZodError = (error: ZodError) => {
return error.issues.map((issue) => ({
field: issue.path.join("."),
issue: issue.message,
}));
export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) => {
return error.issues.map((issue) => {
const issueParams = issue.code === "custom" ? issue.params : undefined;
return {
field: issue.path.join("."),
issue: issue.message ?? "An error occurred while processing your request. Please try again later.",
...(issueParams && { params: issueParams }),
};
});
};
export const logApiRequest = (request: Request, responseStatus: number): void => {

View File

@@ -1,4 +1,6 @@
export type ApiErrorDetails = { field: string; issue: string }[];
import { ZodCustomIssue } from "zod";
export type ApiErrorDetails = { field: string; issue: string; params?: ZodCustomIssue["params"] }[];
export type ApiErrorResponseV2 =
| {

View File

@@ -60,7 +60,7 @@ export const upsertBulkContacts = async (
new Set(filteredContacts.flatMap((contact) => contact.attributes.map((attr) => attr.attributeKey.key)))
);
// 2. Fetch attribute key records for these keys in this environment
// Fetch attribute key records for these keys in this environment
const attributeKeys = await prisma.contactAttributeKey.findMany({
where: {
key: { in: keys },
@@ -73,7 +73,7 @@ export const upsertBulkContacts = async (
return acc;
}, {});
// 2a. Check for missing attribute keys and create them if needed.
// Check for missing attribute keys and create them if needed.
const missingKeysMap = new Map<string, { key: string; name: string }>();
for (const contact of filteredContacts) {
for (const attr of contact.attributes) {
@@ -83,7 +83,7 @@ export const upsertBulkContacts = async (
}
}
// 3. Find existing contacts by matching email attribute
// Find existing contacts by matching email attribute
const existingContacts = await prisma.contact.findMany({
where: {
environmentId,
@@ -128,7 +128,7 @@ export const upsertBulkContacts = async (
}
});
// 4. Split contacts into ones to update and ones to create
// Split contacts into ones to update and ones to create
const contactsToUpdate: {
contactId: string;
attributes: {
@@ -184,12 +184,11 @@ export const upsertBulkContacts = async (
}
}
// 5. Execute everything in ONE transaction
// Execute everything in ONE transaction
await prisma.$transaction(async (tx) => {
// Create missing attribute keys if needed (moved inside transaction)
// Create missing attribute keys if needed
if (missingKeysMap.size > 0) {
const missingKeysArray = Array.from(missingKeysMap.values());
// Create missing attribute keys in a batch
const newAttributeKeys = await tx.contactAttributeKey.createManyAndReturn({
data: missingKeysArray.map((keyObj) => ({
key: keyObj.key,

View File

@@ -3,8 +3,6 @@ import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authent
import { upsertBulkContacts } from "@/modules/ee/contacts/api/bulk/lib/contact";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZUserEmail } from "@formbricks/types/user";
export const PUT = async (request: Request) =>
authenticatedApiClient({
@@ -21,106 +19,22 @@ export const PUT = async (request: Request) =>
const { contacts } = parsedInput.body ?? { contacts: [] };
const { environmentId } = authentication;
const emailKey = "email";
const seenEmails = new Set<string>();
const duplicateEmails = new Set<string>();
for (const contact of contacts) {
const email = contact.attributes.find((attr) => attr.attributeKey.key === emailKey)?.value;
if (email) {
if (seenEmails.has(email)) {
duplicateEmails.add(email);
} else {
seenEmails.add(email);
}
}
}
// Filter out any contacts that have a duplicate email.
// All contacts with an email that appears more than once will be excluded.
const filteredContactsByEmail = contacts.filter((contact) => {
const email = contact.attributes.find((attr) => attr.attributeKey.key === emailKey)?.value;
return email && !duplicateEmails.has(email);
});
if (filteredContactsByEmail.length === 0) {
return responses.badRequestResponse({
details: [
{ field: "contacts", issue: "No valid contacts to process after filtering duplicate emails" },
],
});
}
// duplicate userIds
const seenUserIds = new Set<string>();
const duplicateUserIds = new Set<string>();
for (const contact of filteredContactsByEmail) {
const userId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
if (userId) {
if (seenUserIds.has(userId)) {
duplicateUserIds.add(userId);
} else {
seenUserIds.add(userId);
}
}
}
// userIds need to be unique, so we get rid of all the contacts with duplicate userIds
const filteredContacts = filteredContactsByEmail.filter((contact) => {
const userId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
if (userId) {
return !duplicateUserIds.has(userId);
}
return true;
});
if (filteredContacts.length === 0) {
return responses.badRequestResponse({
details: [
{ field: "contacts", issue: "No valid contacts to process after filtering duplicate userIds" },
],
});
}
const emails = filteredContacts
.map((contact) => contact.attributes.find((attr) => attr.attributeKey.key === emailKey)?.value)
.filter((email): email is string => Boolean(email));
if (!emails.length) {
return responses.badRequestResponse({
details: [
{ field: "contacts", issue: "No email found for any contact, please check your contacts" },
],
});
}
const parsedEmails = z.array(ZUserEmail).safeParse(emails);
if (!parsedEmails.success) {
return responses.badRequestResponse({
details: [
{ field: "contacts", issue: "Invalid email found for some contacts, please check your contacts" },
],
});
}
const { contactIdxWithConflictingUserIds } = await upsertBulkContacts(
filteredContacts,
environmentId,
parsedEmails.data
const emails = contacts.map(
(contact) => contact.attributes.find((attr) => attr.attributeKey.key === "email")?.value!
);
const { contactIdxWithConflictingUserIds } = await upsertBulkContacts(contacts, environmentId, emails);
if (contactIdxWithConflictingUserIds.length) {
return responses.multiStatusResponse({
data: {
status: "success",
message:
"Contacts bulk upload partially successful. Some contacts were skipped due to conflicting userIds.",
skippedContacts: contactIdxWithConflictingUserIds.map((idx) => `contact_${idx + 1}`),
skippedContacts: contactIdxWithConflictingUserIds.map((idx) => ({
index: idx,
userId: contacts[idx].attributes.find((attr) => attr.attributeKey.key === "userId")?.value,
})),
},
});
}
@@ -129,18 +43,7 @@ export const PUT = async (request: Request) =>
data: {
status: "success",
message: "Contacts bulk upload successful",
processed: filteredContacts.length,
...(duplicateEmails.size > 0 || duplicateUserIds.size > 0
? {
skipped: {
total: contacts.length - filteredContacts.length,
conflicts: {
duplicateEmails: Array.from(duplicateEmails),
duplicateUserIds: Array.from(duplicateUserIds),
},
},
}
: {}),
processed: contacts.length,
},
});
},

View File

@@ -132,16 +132,74 @@ export const ZContactBulkUploadRequest = z.object({
.max(1000, { message: "Maximum 1000 contacts allowed at a time." })
.superRefine((contacts, ctx) => {
// every contact must have an email attribute
contacts.forEach((contact, idx) => {
const email = contact.attributes.find((attr) => attr.attributeKey.key === "email");
if (!email) {
if (!email?.value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Missing email attribute for contact ${idx + 1}`,
message: `Missing email attribute for contact at index ${idx}`,
});
}
if (email?.value) {
// parse the email:
const parsedEmail = z.string().email().safeParse(email.value);
if (!parsedEmail.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid email for contact at index ${idx}`,
});
}
}
});
const seenEmails = new Set<string>();
const duplicateEmails = new Set<string>();
const seenUserIds = new Set<string>();
const duplicateUserIds = new Set<string>();
for (const contact of contacts) {
const email = contact.attributes.find((attr) => attr.attributeKey.key === "email")?.value;
const userId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
if (email) {
if (seenEmails.has(email)) {
duplicateEmails.add(email);
} else {
seenEmails.add(email);
}
}
if (userId) {
if (seenUserIds.has(userId)) {
duplicateUserIds.add(userId);
} else {
seenUserIds.add(userId);
}
}
}
if (duplicateEmails.size > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Duplicate emails found in the records, please ensure each email is unique.",
params: {
duplicateEmails: Array.from(duplicateEmails),
},
});
}
// if userId is present, check for duplicate userIds
if (duplicateUserIds.size > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Duplicate userIds found in the records, please ensure each userId is unique.",
params: {
duplicateUserIds: Array.from(duplicateUserIds),
},
});
}
}),
});

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "{environmentType} API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
"api_key_created": "API-Schlüssel erstellt",
"api_key_deleted": "API-Schlüssel gelöscht",
"api_key_label": "API-Schlüssel Label",
"api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.",

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "Add {environmentType} API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
"api_key_created": "API key created",
"api_key_deleted": "API Key deleted",
"api_key_label": "API Key Label",
"api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.",

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "Ajouter la clé API {environmentType}",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
"api_key_created": "Clé API créée",
"api_key_deleted": "Clé API supprimée",
"api_key_label": "Étiquette de clé API",
"api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.",

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "Adicionar chave de API {environmentType}",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
"api_key_created": "Chave da API criada",
"api_key_deleted": "Chave da API deletada",
"api_key_label": "Rótulo da Chave API",
"api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "Adicionar Chave API {environmentType}",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
"api_key_created": "Chave API criada",
"api_key_deleted": "Chave API eliminada",
"api_key_label": "Etiqueta da Chave API",
"api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",

View File

@@ -778,6 +778,7 @@
"add_env_api_key": "新增 '{'environmentType'}' API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
"api_key_created": "API 金鑰已建立",
"api_key_deleted": "API 金鑰已刪除",
"api_key_label": "API 金鑰標籤",
"api_key_security_warning": "為安全起見API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",