fix: adds checks for duplicate attribute keys in the payload

This commit is contained in:
pandeymangg
2025-03-26 12:56:47 +05:30
parent 2f15312d5c
commit 8052ee0aaf
12 changed files with 282 additions and 212 deletions

View File

@@ -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 }),
};
});
};

View File

@@ -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 =
| {

View File

@@ -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" }],
});
}
};

View File

@@ -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"] },

View File

@@ -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,
})),
},
},
});
}

View File

@@ -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,
},
});
}
}),
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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é",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "找不到問卷",