mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
fix: adds logic for returning the duplicate emails and userIds
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",
|
||||
|
||||
Reference in New Issue
Block a user