mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
fix: adds checks for duplicate attribute keys in the payload
This commit is contained in:
@@ -41,7 +41,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
|
||||
return {
|
||||
field: issue.path.join("."),
|
||||
issue: issue.message ?? "An error occurred while processing your request. Please try again later.",
|
||||
...(issueParams && { params: issueParams }),
|
||||
...(issueParams && { meta: issueParams }),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ZodCustomIssue } from "zod";
|
||||
|
||||
export type ApiErrorDetails = { field: string; issue: string; params?: ZodCustomIssue["params"] }[];
|
||||
// We're naming the "params" field from zod (or otherwise) to "meta" since "params" is a bit confusing
|
||||
// We're still using the "params" type from zod though because it allows us to not reference `any` and directly use the zod types
|
||||
export type ApiErrorDetails = {
|
||||
field: string;
|
||||
issue: string;
|
||||
meta?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}[];
|
||||
|
||||
export type ApiErrorResponseV2 =
|
||||
| {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
|
||||
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const upsertBulkContacts = async (
|
||||
contacts: TContactBulkUploadContact[],
|
||||
environmentId: string,
|
||||
parsedEmails: string[]
|
||||
): Promise<{
|
||||
contactIdxWithConflictingUserIds: number[];
|
||||
}> => {
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
contactIdxWithConflictingUserIds: number[];
|
||||
},
|
||||
ApiErrorResponseV2
|
||||
>
|
||||
> => {
|
||||
const contactIdxWithConflictingUserIds: number[] = [];
|
||||
|
||||
const userIdsInContacts = contacts.flatMap((contact) =>
|
||||
@@ -54,9 +61,9 @@ export const upsertBulkContacts = async (
|
||||
});
|
||||
|
||||
if (!filteredContacts.length) {
|
||||
return {
|
||||
return ok({
|
||||
contactIdxWithConflictingUserIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const emailAttributeKey = "email";
|
||||
@@ -190,73 +197,74 @@ export const upsertBulkContacts = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Execute everything in ONE transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create missing attribute keys if needed
|
||||
if (missingKeysMap.size > 0) {
|
||||
const missingKeysArray = Array.from(missingKeysMap.values());
|
||||
const newAttributeKeys = await tx.contactAttributeKey.createManyAndReturn({
|
||||
data: missingKeysArray.map((keyObj) => ({
|
||||
key: keyObj.key,
|
||||
name: keyObj.name,
|
||||
environmentId,
|
||||
})),
|
||||
select: { key: true, id: true },
|
||||
skipDuplicates: true,
|
||||
});
|
||||
try {
|
||||
// Execute everything in ONE transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Create missing attribute keys if needed
|
||||
if (missingKeysMap.size > 0) {
|
||||
const missingKeysArray = Array.from(missingKeysMap.values());
|
||||
const newAttributeKeys = await tx.contactAttributeKey.createManyAndReturn({
|
||||
data: missingKeysArray.map((keyObj) => ({
|
||||
key: keyObj.key,
|
||||
name: keyObj.name,
|
||||
environmentId,
|
||||
})),
|
||||
select: { key: true, id: true },
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
// Refresh the attribute key map for the missing keys
|
||||
for (const attrKey of newAttributeKeys) {
|
||||
attributeKeyMap[attrKey.key] = attrKey.id;
|
||||
// Refresh the attribute key map for the missing keys
|
||||
for (const attrKey of newAttributeKeys) {
|
||||
attributeKeyMap[attrKey.key] = attrKey.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new contacts
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
environmentId,
|
||||
}));
|
||||
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
// Create new contacts
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
environmentId,
|
||||
}));
|
||||
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
}
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
// Prepare attributes for both new and existing contacts
|
||||
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: attr.value,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
if (attributesToUpsert.length > 0) {
|
||||
// Process attributes in batches of 10,000
|
||||
const BATCH_SIZE = 10000;
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
|
||||
)
|
||||
@@ -271,35 +279,43 @@ export const upsertBulkContacts = async (
|
||||
"value" = EXCLUDED."value",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
contactCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContacts) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
});
|
||||
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
});
|
||||
|
||||
// revalidate all the new contacts:
|
||||
for (const newContact of newContacts) {
|
||||
contactCache.revalidate({
|
||||
id: newContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
// revalidate all the existing contacts:
|
||||
for (const existingContact of existingContacts) {
|
||||
contactCache.revalidate({
|
||||
id: existingContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
contactAttributeKeyCache.revalidate({
|
||||
environmentId,
|
||||
return ok({
|
||||
contactIdxWithConflictingUserIds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
contactAttributeCache.revalidate({ environmentId });
|
||||
});
|
||||
|
||||
return {
|
||||
contactIdxWithConflictingUserIds,
|
||||
};
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "error", issue: "Failed to upsert contacts" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,11 +5,12 @@ import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/cont
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
// Ensure that createId always returns "mock-id" for predictability
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
createId: vi.fn(() => "mock-id"),
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
// Mock prisma methods
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contactAttribute: {
|
||||
@@ -65,7 +66,7 @@ describe("upsertBulkContacts", () => {
|
||||
});
|
||||
|
||||
test("should create new contacts when all provided contacts have unique user IDs and emails", async () => {
|
||||
// Mock data
|
||||
// Mock data: two contacts with unique userId and email
|
||||
const mockContacts = [
|
||||
{
|
||||
attributes: [
|
||||
@@ -85,27 +86,26 @@ describe("upsertBulkContacts", () => {
|
||||
|
||||
const mockParsedEmails = ["john@example.com", "jane@example.com"];
|
||||
|
||||
// Mock existing user IDs (none for this test case)
|
||||
// Mock: no existing userIds in DB
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
// Mock attribute keys
|
||||
// Mock: all attribute keys already exist
|
||||
const mockAttributeKeys = [
|
||||
{ id: "attr-key-email", key: "email", environmentId: mockEnvironmentId },
|
||||
{ id: "attr-key-userId", key: "userId", environmentId: mockEnvironmentId },
|
||||
{ id: "attr-key-name", key: "name", environmentId: mockEnvironmentId },
|
||||
];
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce(mockAttributeKeys);
|
||||
|
||||
// Mock existing contacts (none for this test case)
|
||||
// Mock: no existing contacts by email
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
// Execute the function
|
||||
const result = await upsertBulkContacts(mockContacts, mockEnvironmentId, mockParsedEmails);
|
||||
|
||||
// Assertions
|
||||
expect(result).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
// Assert that the result is ok and data is as expected
|
||||
if (!result.ok) throw new Error("Expected result.ok to be true");
|
||||
expect(result.data).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
|
||||
// Verify that the function checked for existing user IDs
|
||||
// Verify that existing user IDs were checked
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributeKey: {
|
||||
@@ -116,12 +116,10 @@ describe("upsertBulkContacts", () => {
|
||||
in: ["user-123", "user-456"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
},
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
// Verify that the function fetched attribute keys
|
||||
// Verify that attribute keys were fetched
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
key: { in: ["email", "userId", "name"] },
|
||||
@@ -129,7 +127,7 @@ describe("upsertBulkContacts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify that the function checked for existing contacts by email
|
||||
// Verify that existing contacts were looked up by email
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
@@ -153,7 +151,7 @@ describe("upsertBulkContacts", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify that new contacts were created
|
||||
// Verify that new contacts were created in the transaction
|
||||
expect(prisma.contact.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ id: "mock-id", environmentId: mockEnvironmentId },
|
||||
@@ -161,54 +159,55 @@ describe("upsertBulkContacts", () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Verify that the raw SQL query was executed for inserting attributes
|
||||
// Verify that the raw SQL query was executed to upsert attributes
|
||||
expect(prisma.$executeRaw).toHaveBeenCalled();
|
||||
|
||||
// Verify that caches were revalidated
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "mock-id",
|
||||
});
|
||||
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({ environmentId: mockEnvironmentId });
|
||||
// Since two new contacts are created with same id "mock-id", expect at least one revalidation with id "mock-id"
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({ id: "mock-id" });
|
||||
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ environmentId: mockEnvironmentId });
|
||||
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({ environmentId: mockEnvironmentId });
|
||||
});
|
||||
|
||||
test("should update existing contacts when provided contacts have existing userIds", async () => {
|
||||
// Mock data
|
||||
test("should update existing contacts when provided contacts match an existing email", async () => {
|
||||
// Mock data: a contact that exists in the DB
|
||||
const mockContacts = [
|
||||
{
|
||||
attributes: [{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" }],
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" },
|
||||
// No userId is provided so it should be treated as update
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockParsedEmails = ["john@example.com"];
|
||||
|
||||
// Mock existing user IDs (none for this test case)
|
||||
// Mock: no existing userIds conflict
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
// Mock attribute keys
|
||||
// Mock: attribute keys for email exist
|
||||
const mockAttributeKeys = [{ id: "attr-key-email", key: "email", environmentId: mockEnvironmentId }];
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce(mockAttributeKeys);
|
||||
|
||||
// Mock existing contacts
|
||||
// Mock: an existing contact with the same email
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "existing-contact-id",
|
||||
attributes: [{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" }],
|
||||
attributes: [
|
||||
{
|
||||
id: "existing-email-attr",
|
||||
attributeKey: { key: "email", name: "Email" },
|
||||
value: "john@example.com",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Execute the function
|
||||
const result = await upsertBulkContacts(mockContacts, mockEnvironmentId, mockParsedEmails);
|
||||
|
||||
// Assertions
|
||||
expect(result).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
if (!result.ok) throw new Error("Expected result.ok to be true");
|
||||
expect(result.data).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
});
|
||||
|
||||
test("should return the indices of contacts with conflicting user IDs", async () => {
|
||||
@@ -267,121 +266,120 @@ describe("upsertBulkContacts", () => {
|
||||
// Execute the function
|
||||
const result = await upsertBulkContacts(mockContacts, mockEnvironmentId, mockParsedEmails);
|
||||
|
||||
// Assertions - verify that the function correctly identified contacts with conflicting user IDs
|
||||
expect(result.contactIdxWithConflictingUserIds).toEqual([1, 3]);
|
||||
if (result.ok) {
|
||||
// Assertions - verify that the function correctly identified contacts with conflicting user IDs
|
||||
expect(result.data.contactIdxWithConflictingUserIds).toEqual([1, 3]);
|
||||
|
||||
// Verify that the function checked for existing user IDs
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributeKey: {
|
||||
// Verify that the function checked for existing user IDs
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributeKey: {
|
||||
environmentId: mockEnvironmentId,
|
||||
key: "userId",
|
||||
},
|
||||
value: {
|
||||
in: ["user-123", "existing-user-1", "existing-user-2"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify that the function fetched attribute keys for the filtered contacts (without conflicting userIds)
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalled();
|
||||
|
||||
// Verify that the function checked for existing contacts by email
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
key: "userId",
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "email" },
|
||||
value: { in: mockParsedEmails },
|
||||
},
|
||||
},
|
||||
},
|
||||
value: {
|
||||
in: ["user-123", "existing-user-1", "existing-user-2"],
|
||||
select: {
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
createdAt: true,
|
||||
id: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Verify that the function fetched attribute keys for the filtered contacts (without conflicting userIds)
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalled();
|
||||
// Verify that only non-conflicting contacts were processed
|
||||
expect(prisma.contact.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ id: "mock-id", environmentId: mockEnvironmentId },
|
||||
{ id: "mock-id", environmentId: mockEnvironmentId },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify that the function checked for existing contacts by email
|
||||
expect(prisma.contact.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
// Verify that the transaction was executed
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
// Verify that caches were revalidated
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "email" },
|
||||
value: { in: mockParsedEmails },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
createdAt: true,
|
||||
id: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify that only non-conflicting contacts were processed
|
||||
expect(prisma.contact.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ id: "mock-id", environmentId: mockEnvironmentId },
|
||||
{ id: "mock-id", environmentId: mockEnvironmentId },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify that the transaction was executed
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
// Verify that caches were revalidated
|
||||
expect(contactCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
});
|
||||
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockEnvironmentId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should create missing attribute keys when they are not found in the database", async () => {
|
||||
// Mock data with some attributes that don't exist in the database
|
||||
// Mock data: contacts with attributes that include missing attribute keys
|
||||
const mockContacts = [
|
||||
{
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email", name: "Email" }, value: "john@example.com" },
|
||||
{ attributeKey: { key: "newKey1", name: "New Key 1" }, value: "value1" }, // New attribute key
|
||||
{ attributeKey: { key: "newKey1", name: "New Key 1" }, value: "value1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email", name: "Email" }, value: "jane@example.com" },
|
||||
{ attributeKey: { key: "newKey2", name: "New Key 2" }, value: "value2" }, // New attribute key
|
||||
{ attributeKey: { key: "newKey2", name: "New Key 2" }, value: "value2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockParsedEmails = ["john@example.com", "jane@example.com"];
|
||||
|
||||
// Mock existing user IDs (none for this test case)
|
||||
// Mock: no existing user IDs
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
// Mock attribute keys - only "email" exists, "newKey1" and "newKey2" are missing
|
||||
// Mock: only "email" exists; new keys are missing
|
||||
const mockAttributeKeys = [{ id: "attr-key-email", key: "email", environmentId: mockEnvironmentId }];
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValueOnce(mockAttributeKeys);
|
||||
|
||||
// Mock the creation of new attribute keys
|
||||
// Mock: creation of new attribute keys returns new keys
|
||||
const mockNewAttributeKeys = [
|
||||
{ id: "attr-key-newKey1", key: "newKey1" },
|
||||
{ id: "attr-key-newKey2", key: "newKey2" },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.contactAttributeKey.createManyAndReturn).mockResolvedValueOnce(
|
||||
mockNewAttributeKeys as unknown as any
|
||||
mockNewAttributeKeys as any
|
||||
);
|
||||
|
||||
// Mock existing contacts (none for this test case)
|
||||
// Mock: no existing contacts for update
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
// Execute the function
|
||||
const result = await upsertBulkContacts(mockContacts, mockEnvironmentId, mockParsedEmails);
|
||||
|
||||
// Assertions
|
||||
expect(result).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
if (!result.ok) throw new Error("Expected result.ok to be true");
|
||||
expect(result.data).toEqual({ contactIdxWithConflictingUserIds: [] });
|
||||
|
||||
// Verify that the function fetched attribute keys
|
||||
// Verify that attribute keys were fetched for all keys
|
||||
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
key: { in: ["email", "newKey1", "newKey2"] },
|
||||
|
||||
@@ -27,7 +27,13 @@ export const PUT = async (request: Request) =>
|
||||
(contact) => contact.attributes.find((attr) => attr.attributeKey.key === "email")?.value!
|
||||
);
|
||||
|
||||
const { contactIdxWithConflictingUserIds } = await upsertBulkContacts(contacts, environmentId, emails);
|
||||
const upsertBulkContactsResult = await upsertBulkContacts(contacts, environmentId, emails);
|
||||
|
||||
if (!upsertBulkContactsResult.ok) {
|
||||
return handleApiError(request, upsertBulkContactsResult.error);
|
||||
}
|
||||
|
||||
const { contactIdxWithConflictingUserIds } = upsertBulkContactsResult.data;
|
||||
|
||||
if (contactIdxWithConflictingUserIds.length) {
|
||||
return responses.multiStatusResponse({
|
||||
@@ -35,10 +41,12 @@ export const PUT = async (request: Request) =>
|
||||
status: "success",
|
||||
message:
|
||||
"Contacts bulk upload partially successful. Some contacts were skipped due to conflicting userIds.",
|
||||
skippedContacts: contactIdxWithConflictingUserIds.map((idx) => ({
|
||||
index: idx,
|
||||
userId: contacts[idx].attributes.find((attr) => attr.attributeKey.key === "userId")?.value,
|
||||
})),
|
||||
meta: {
|
||||
skippedContacts: contactIdxWithConflictingUserIds.map((idx) => ({
|
||||
index: idx,
|
||||
userId: contacts[idx].attributes.find((attr) => attr.attributeKey.key === "userId")?.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,6 +200,36 @@ export const ZContactBulkUploadRequest = z.object({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contactsWithDuplicateKeys = contacts
|
||||
.map((contact, idx) => {
|
||||
// Count how many times each attribute key appears
|
||||
const keyCounts = contact.attributes.reduce<Record<string, number>>((acc, attr) => {
|
||||
const key = attr.attributeKey.key;
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Find attribute keys that appear more than once
|
||||
const duplicateKeys = Object.entries(keyCounts)
|
||||
.filter(([_, count]) => count > 1)
|
||||
.map(([key]) => key);
|
||||
|
||||
return { idx, duplicateKeys };
|
||||
})
|
||||
// Only keep contacts that have at least one duplicate key
|
||||
.filter(({ duplicateKeys }) => duplicateKeys.length > 0);
|
||||
|
||||
if (contactsWithDuplicateKeys.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Duplicate attribute keys found in the records, please ensure each attribute key is unique.",
|
||||
params: {
|
||||
contactsWithDuplicateKeys,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation nicht gefunden",
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "Zusammenfassung",
|
||||
"survey": "Umfrage",
|
||||
"survey_completed": "Umfrage abgeschlossen.",
|
||||
"survey_id": "Umfrage-ID",
|
||||
"survey_languages": "Umfragesprachen",
|
||||
"survey_live": "Umfrage live",
|
||||
"survey_not_found": "Umfrage nicht gefunden",
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"or": "or",
|
||||
"organization": "Organization",
|
||||
"organization_id": "Organization ID",
|
||||
"organization_not_found": "Organization not found",
|
||||
"organization_teams_not_found": "Organization teams not found",
|
||||
"other": "Other",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "Summary",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
"survey_id": "Survey ID",
|
||||
"survey_languages": "Survey Languages",
|
||||
"survey_live": "Survey live",
|
||||
"survey_not_found": "Survey not found",
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "ID de l'organisation",
|
||||
"organization_not_found": "Organisation non trouvée",
|
||||
"organization_teams_not_found": "Équipes d'organisation non trouvées",
|
||||
"other": "Autre",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "Résumé",
|
||||
"survey": "Enquête",
|
||||
"survey_completed": "Enquête terminée.",
|
||||
"survey_id": "ID de l'enquête",
|
||||
"survey_languages": "Langues de l'enquête",
|
||||
"survey_live": "Sondage en direct",
|
||||
"survey_not_found": "Sondage non trouvé",
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"or": "ou",
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_teams_not_found": "Equipes da organização não encontradas",
|
||||
"other": "outro",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "Resumo",
|
||||
"survey": "Pesquisa",
|
||||
"survey_completed": "Pesquisa concluída.",
|
||||
"survey_id": "ID da Pesquisa",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Pesquisa ao vivo",
|
||||
"survey_not_found": "Pesquisa não encontrada",
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"or": "ou",
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_teams_not_found": "Equipas da organização não encontradas",
|
||||
"other": "Outro",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "Resumo",
|
||||
"survey": "Inquérito",
|
||||
"survey_completed": "Inquérito concluído.",
|
||||
"survey_id": "ID do Inquérito",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Inquérito ao vivo",
|
||||
"survey_not_found": "Inquérito não encontrado",
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
"organization_not_found": "找不到組織",
|
||||
"organization_teams_not_found": "找不到組織團隊",
|
||||
"other": "其他",
|
||||
@@ -354,6 +355,7 @@
|
||||
"summary": "摘要",
|
||||
"survey": "問卷",
|
||||
"survey_completed": "問卷已完成。",
|
||||
"survey_id": "問卷 ID",
|
||||
"survey_languages": "問卷語言",
|
||||
"survey_live": "問卷已上線",
|
||||
"survey_not_found": "找不到問卷",
|
||||
|
||||
Reference in New Issue
Block a user