diff --git a/apps/web/app/middleware/endpoint-validator.ts b/apps/web/app/middleware/endpoint-validator.ts index ef079a6ba7..ddf7b46c85 100644 --- a/apps/web/app/middleware/endpoint-validator.ts +++ b/apps/web/app/middleware/endpoint-validator.ts @@ -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); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 88a612cd33..aa22825818 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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", ], }; diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts index f429c70240..fd9e3894e9 100644 --- a/apps/web/modules/api/v2/lib/utils.ts +++ b/apps/web/modules/api/v2/lib/utils.ts @@ -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 => { diff --git a/apps/web/modules/api/v2/types/api-error.ts b/apps/web/modules/api/v2/types/api-error.ts index 06e69c3f49..da9e4c62ab 100644 --- a/apps/web/modules/api/v2/types/api-error.ts +++ b/apps/web/modules/api/v2/types/api-error.ts @@ -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 = | { diff --git a/apps/web/modules/ee/contacts/api/bulk/lib/contact.ts b/apps/web/modules/ee/contacts/api/bulk/lib/contact.ts index ba8447e511..213b664646 100644 --- a/apps/web/modules/ee/contacts/api/bulk/lib/contact.ts +++ b/apps/web/modules/ee/contacts/api/bulk/lib/contact.ts @@ -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(); 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, diff --git a/apps/web/modules/ee/contacts/api/bulk/route.ts b/apps/web/modules/ee/contacts/api/bulk/route.ts index 0639004e43..0fbe000501 100644 --- a/apps/web/modules/ee/contacts/api/bulk/route.ts +++ b/apps/web/modules/ee/contacts/api/bulk/route.ts @@ -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(); - const duplicateEmails = new Set(); - - 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(); - const duplicateUserIds = new Set(); - - 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, }, }); }, diff --git a/apps/web/modules/ee/contacts/types/contact.ts b/apps/web/modules/ee/contacts/types/contact.ts index 24851c0286..95682bd8f4 100644 --- a/apps/web/modules/ee/contacts/types/contact.ts +++ b/apps/web/modules/ee/contacts/types/contact.ts @@ -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(); + const duplicateEmails = new Set(); + + const seenUserIds = new Set(); + const duplicateUserIds = new Set(); + + 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), + }, + }); + } }), }); diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json index 0df60b8f47..5fa10a866a 100644 --- a/packages/lib/messages/de-DE.json +++ b/packages/lib/messages/de-DE.json @@ -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.", diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json index fbee8f6571..77184b3b51 100644 --- a/packages/lib/messages/en-US.json +++ b/packages/lib/messages/en-US.json @@ -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.", diff --git a/packages/lib/messages/fr-FR.json b/packages/lib/messages/fr-FR.json index 673c4c2ca9..a385275125 100644 --- a/packages/lib/messages/fr-FR.json +++ b/packages/lib/messages/fr-FR.json @@ -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.", diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json index 5c98d90d88..e54328a00d 100644 --- a/packages/lib/messages/pt-BR.json +++ b/packages/lib/messages/pt-BR.json @@ -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.", diff --git a/packages/lib/messages/pt-PT.json b/packages/lib/messages/pt-PT.json index d62e80caf7..b221b59b8b 100644 --- a/packages/lib/messages/pt-PT.json +++ b/packages/lib/messages/pt-PT.json @@ -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.", diff --git a/packages/lib/messages/zh-Hant-TW.json b/packages/lib/messages/zh-Hant-TW.json index d1233f54d7..96806e7b08 100644 --- a/packages/lib/messages/zh-Hant-TW.json +++ b/packages/lib/messages/zh-Hant-TW.json @@ -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 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",