mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-18 10:09:49 -06:00
Compare commits
27 Commits
fix-update
...
epic/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49f40610b | ||
|
|
9e754bad9c | ||
|
|
4dcf6fda40 | ||
|
|
1b8ccd7199 | ||
|
|
4f9088559f | ||
|
|
18550f1d11 | ||
|
|
881cd31f74 | ||
|
|
e00405dca2 | ||
|
|
33542d0c54 | ||
|
|
f37d22f13d | ||
|
|
202ae903ac | ||
|
|
6ab5cc367c | ||
|
|
21559045ba | ||
|
|
d7c57a7a48 | ||
|
|
11b2ef4788 | ||
|
|
6fefd51cce | ||
|
|
65af826222 | ||
|
|
12eb54c653 | ||
|
|
5aa1427e64 | ||
|
|
08ac490512 | ||
|
|
4538c7bbcb | ||
|
|
7495c04048 | ||
|
|
85a1318f77 | ||
|
|
22ae0a731e | ||
|
|
f7e8bc1630 | ||
|
|
36f091bc73 | ||
|
|
091b78d1e3 |
@@ -229,5 +229,14 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
6
.github/workflows/release-helm-chart.yml
vendored
6
.github/workflows/release-helm-chart.yml
vendored
@@ -65,8 +65,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
echo "Packaging Helm chart version: ${VERSION}"
|
||||
helm package ./helm-chart
|
||||
helm package ./charts/formbricks
|
||||
|
||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
4
.github/workflows/sonarqube.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
@@ -50,6 +51,9 @@ jobs:
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.verbose=true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -126,6 +126,22 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
# Pino loads transport code in worker threads via dynamic require().
|
||||
# Next.js file tracing only traces static imports, missing runtime-loaded files
|
||||
# (e.g. pino/lib/transport-stream.js, transport targets).
|
||||
# Copy the full packages to ensure all runtime files are available.
|
||||
COPY --from=installer /app/node_modules/pino ./node_modules/pino
|
||||
RUN chmod -R 755 ./node_modules/pino
|
||||
|
||||
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
|
||||
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
|
||||
|
||||
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||
RUN chmod -R 755 ./node_modules/pino-abstract-transport
|
||||
|
||||
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
|
||||
RUN chmod -R 755 ./node_modules/otlp-logger
|
||||
|
||||
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||
RUN npm install --ignore-scripts -g prisma@6 \
|
||||
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||
|
||||
@@ -109,7 +109,10 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
|
||||
@@ -38,7 +38,11 @@ const getBadgeConfig = (
|
||||
}
|
||||
};
|
||||
|
||||
export const EnterpriseLicenseStatus = ({ status, gracePeriodEnd, environmentId }: EnterpriseLicenseStatusProps) => {
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -280,7 +280,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -30,7 +30,10 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const convertedJsonInput = convertDatesInObject(jsonInput);
|
||||
const convertedJsonInput = convertDatesInObject(
|
||||
jsonInput,
|
||||
new Set(["contactAttributes", "variables", "data", "meta"])
|
||||
);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
@@ -6,18 +7,29 @@ import {
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const url = new URL(req.url);
|
||||
const environmentId = url.searchParams.get("state");
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
// Deprecated: This api route is deprecated now and will be removed in the future.
|
||||
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
|
||||
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
|
||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
|
||||
const validateInput = (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
return { isValid: true, data: inputValidation.data };
|
||||
};
|
||||
|
||||
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return false;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
logger.error({ environmentId }, "Organization does not exist");
|
||||
|
||||
// fail closed if the organization does not exist
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(req);
|
||||
|
||||
// validate using zod
|
||||
const validation = validateInput(params.environmentId, params.userId);
|
||||
if (!validation.isValid) {
|
||||
return { response: validation.error };
|
||||
}
|
||||
|
||||
const { environmentId, userId } = validation.data;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ ...state }, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
"Unable to handle the request: " + error.message,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContact } from "@/modules/ee/contacts/types/contact";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
|
||||
const contactMock: Partial<TContact> & {
|
||||
attributes: { value: string; attributeKey: { key: string } }[];
|
||||
} = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: userId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contact if found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toEqual(contactMock);
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
attributes: {
|
||||
value: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
}[];
|
||||
id: string;
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
);
|
||||
@@ -1,323 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getSyncSurveys } from "./survey";
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
anySurveyHasFilters: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
diffInDays: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
evaluateSegment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
display: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const contactId = "test-contact-id";
|
||||
const contactAttributes = { userId: "user1", email: "test@example.com" };
|
||||
const deviceType = "desktop";
|
||||
|
||||
const mockProject = {
|
||||
id: "proj1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org1",
|
||||
environments: [],
|
||||
recontactDays: 10,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey 1",
|
||||
environmentId: environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
triggers: [],
|
||||
languages: [],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
};
|
||||
|
||||
// Helper function to create mock display objects
|
||||
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||
id,
|
||||
createdAt: createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId,
|
||||
contactId,
|
||||
responseId: null,
|
||||
status: null,
|
||||
});
|
||||
|
||||
// Helper function to create mock response objects
|
||||
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||
id,
|
||||
createdAt: createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: false,
|
||||
surveyId,
|
||||
contactId,
|
||||
endingId: null,
|
||||
data: {},
|
||||
variables: {},
|
||||
ttc: {},
|
||||
meta: {},
|
||||
contactAttributes: null,
|
||||
singleUseId: null,
|
||||
language: null,
|
||||
displayId: null,
|
||||
});
|
||||
|
||||
describe("getSyncSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should throw error if product not found", async () => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
"Project not found"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return empty array if no surveys found", async () => {
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array if no 'app' type surveys in progress", async () => {
|
||||
const surveys: TSurvey[] = [
|
||||
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
|
||||
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
|
||||
];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayOnce'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displaySome'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
createMockDisplay("d1", "s1", contactId),
|
||||
createMockDisplay("d2", "s1", contactId),
|
||||
]); // Display limit reached
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
|
||||
// Test with response already submitted
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result3).toEqual([]);
|
||||
});
|
||||
|
||||
test("should not filter by displayOption 'respondMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by product recontactDays if survey recontactDays is null", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const displayDate = new Date();
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
|
||||
]);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should return surveys if no segment filters exist", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should evaluate segment filters if they exist", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
|
||||
// Case 1: Segment evaluation matches
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result1).toEqual(surveys);
|
||||
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||
{
|
||||
attributes: contactAttributes,
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId,
|
||||
userId: contactAttributes.userId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
// Case 2: Segment evaluation does not match
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false);
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(getSurveys).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError);
|
||||
});
|
||||
|
||||
test("should handle general errors", async () => {
|
||||
const generalError = new Error("Something went wrong");
|
||||
vi.mocked(getSurveys).mockRejectedValue(generalError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
generalError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
|
||||
|
||||
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
|
||||
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
|
||||
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
|
||||
// Let's assume the filter logic works correctly and test the intended path.
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]); // Expect empty array, not an error in this case.
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
|
||||
export const getSyncSurveys = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
contactAttributes: Record<string, string | number>,
|
||||
deviceType: "phone" | "desktop" = "desktop"
|
||||
): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
let surveys = await getSurveys(environmentId);
|
||||
|
||||
// filtered surveys for running and web
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
|
||||
|
||||
// if no surveys are left, return an empty array
|
||||
if (surveys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
|
||||
// filter surveys that meet the displayOption criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
switch (survey.displayOption) {
|
||||
case "respondMultiple":
|
||||
return true;
|
||||
case "displayOnce":
|
||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||
case "displayMultiple":
|
||||
if (!responses) return true;
|
||||
else {
|
||||
return responses.filter((response) => response.surveyId === survey.id).length === 0;
|
||||
}
|
||||
case "displaySome":
|
||||
if (survey.displayLimit === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
|
||||
default:
|
||||
throw Error("Invalid displayOption");
|
||||
}
|
||||
});
|
||||
|
||||
const latestDisplay = displays[0];
|
||||
|
||||
// filter surveys that meet the recontactDays criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
if (!latestDisplay) {
|
||||
return true;
|
||||
} else if (survey.recontactDays !== null) {
|
||||
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
|
||||
if (!lastDisplaySurvey) {
|
||||
return true;
|
||||
}
|
||||
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
|
||||
} else if (project.recontactDays !== null) {
|
||||
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// if no surveys are left, return an empty array
|
||||
if (surveys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// if no surveys have segment filters, return the surveys
|
||||
if (!anySurveyHasFilters(surveys)) {
|
||||
return surveys;
|
||||
}
|
||||
|
||||
// the surveys now have segment filters, so we need to evaluate them
|
||||
const surveyPromises = surveys.map(async (survey) => {
|
||||
const { segment } = survey;
|
||||
// if the survey has no segment, or the segment has no filters, we return the survey
|
||||
if (!segment || !segment.filters?.length) {
|
||||
return survey;
|
||||
}
|
||||
|
||||
// Evaluate the segment filters
|
||||
const result = await evaluateSegment(
|
||||
{
|
||||
attributes: contactAttributes ?? {},
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId,
|
||||
userId: String(contactAttributes.userId),
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
return result ? survey : null;
|
||||
});
|
||||
|
||||
const resolvedSurveys = await Promise.all(surveyPromises);
|
||||
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
|
||||
|
||||
if (!surveys) {
|
||||
throw new ResourceNotFoundError("Survey", environmentId);
|
||||
}
|
||||
return surveys;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,245 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEnding,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { replaceAttributeRecall } from "./utils";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
parseRecallInfo: vi.fn((text, attributes) => {
|
||||
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
|
||||
const match = text.match(recallPattern);
|
||||
if (match && match[1]) {
|
||||
const recallKey = match[1];
|
||||
const attributeValue = attributes[recallKey];
|
||||
if (attributeValue !== undefined) {
|
||||
return text.replace(recallPattern, `parsed-${attributeValue}`);
|
||||
}
|
||||
}
|
||||
return text; // Return original text if no match or attribute not found
|
||||
}),
|
||||
}));
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
plan: "premium",
|
||||
};
|
||||
|
||||
describe("replaceAttributeRecall", () => {
|
||||
test("should replace recall info in question headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!" },
|
||||
subheader: { default: "Your email is recall:email" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in welcome card headline", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome, recall:name!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in end screen headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you, recall:name!" },
|
||||
subheader: { default: "Your plan: recall:plan" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
buttonLink: "https://example.com",
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.endings[0].type).toBe("endScreen");
|
||||
if (result.endings[0].type === "endScreen") {
|
||||
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
|
||||
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple languages", () => {
|
||||
const surveyMultiLang: TSurvey = {
|
||||
...baseSurvey,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next", es: "Siguiente" },
|
||||
placeholder: { default: "Type here...", es: "Escribe aquí..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyMultiLang, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should not replace if recall key is not in attributes", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Your company: recall:company" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
|
||||
});
|
||||
|
||||
test("should handle surveys with no recall information", async () => {
|
||||
const surveyNoRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Just a regular question" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyNoRecall, attributes);
|
||||
expect(result).toEqual(surveyNoRecall); // Should be unchanged
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
|
||||
const surveyEmpty: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyEmpty, attributes);
|
||||
expect(result).toEqual(surveyEmpty);
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
|
||||
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
|
||||
const surveyTemp = structuredClone(survey);
|
||||
const languages = surveyTemp.languages
|
||||
.map((surveyLanguage) => {
|
||||
if (surveyLanguage.default) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (surveyLanguage.enabled) {
|
||||
return surveyLanguage.language.code;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((language): language is string => language !== null);
|
||||
|
||||
surveyTemp.questions.forEach((question) => {
|
||||
languages.forEach((language) => {
|
||||
if (question.headline[language]?.includes("recall:")) {
|
||||
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
|
||||
}
|
||||
if (question.subheader && question.subheader[language]?.includes("recall:")) {
|
||||
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
|
||||
languages.forEach((language) => {
|
||||
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
|
||||
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
|
||||
surveyTemp.welcomeCard.headline[language],
|
||||
attributes
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
surveyTemp.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
languages.forEach((language) => {
|
||||
if (ending.headline && ending.headline[language]?.includes("recall:")) {
|
||||
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
|
||||
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
|
||||
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return surveyTemp;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
ENCRYPTION_KEY,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -10,10 +10,17 @@ import {
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
@@ -26,6 +33,13 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
|
||||
@@ -5,12 +5,19 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
@@ -23,6 +30,13 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -68,7 +68,6 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
|
||||
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
|
||||
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
|
||||
isIntegrationRoute: vi.fn().mockReturnValue(false),
|
||||
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -82,7 +81,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
api: {
|
||||
client: { windowMs: 60000, max: 100 },
|
||||
v1: { windowMs: 60000, max: 1000 },
|
||||
syncUserIdentification: { windowMs: 60000, max: 50 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -463,45 +461,6 @@ describe("withV1ApiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles sync user identification rate limiting", async () => {
|
||||
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const {
|
||||
isClientSideApiRoute,
|
||||
isManagementApiRoute,
|
||||
isIntegrationRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} = await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
|
||||
userId: "user-123",
|
||||
environmentId: "env-123",
|
||||
});
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
const rateLimitError = new Error("Sync rate limit exceeded");
|
||||
rateLimitError.message = "Sync rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ windowMs: 60000, max: 50 }),
|
||||
"user-123"
|
||||
);
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -48,23 +47,16 @@ enum ApiV1RouteTypeEnum {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client-side API rate limiting (IP-based or sync-specific)
|
||||
* Apply client-side API rate limiting (IP-based)
|
||||
*/
|
||||
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
|
||||
if (syncEndpoint) {
|
||||
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
|
||||
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
|
||||
} else {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
}
|
||||
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
url: string,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
@@ -84,7 +76,7 @@ const handleRateLimiting = async (
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
await applyClientRateLimit(url, customRateLimitConfig);
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
@@ -255,7 +247,6 @@ const getRouteType = (
|
||||
* Features:
|
||||
* - Performs authentication once and passes result to handler
|
||||
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
|
||||
* - Includes additional sync user identification rate limiting for client-side sync endpoints
|
||||
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
|
||||
* - System and Sentry logs are always called for non-success responses
|
||||
* - Uses function overloads to provide type safety without requiring type guards
|
||||
@@ -328,12 +319,7 @@ export const withV1ApiWrapper: {
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req.nextUrl.pathname,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isManagementApiRoute,
|
||||
isPublicDomainRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
@@ -270,58 +269,6 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncWithUserIdentificationEndpoint", () => {
|
||||
test("should return environmentId and userId for valid sync URLs", () => {
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "abc-123",
|
||||
userId: "xyz-789",
|
||||
});
|
||||
|
||||
const result3 = isSyncWithUserIdentificationEndpoint(
|
||||
"/api/v1/client/env_123_test/app/sync/user_456_test"
|
||||
);
|
||||
expect(result3).toEqual({
|
||||
environmentId: "env_123_test",
|
||||
userId: "user_456_test",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle optional trailing slash", () => {
|
||||
// Test both with and without trailing slash
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid sync URLs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
|
||||
});
|
||||
|
||||
test("should handle empty or malformed IDs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicDomainRoute", () => {
|
||||
test("should return true for health endpoint", () => {
|
||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
||||
@@ -582,12 +529,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle special characters in survey IDs", () => {
|
||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
|
||||
).toEqual({
|
||||
environmentId: "env-123_test",
|
||||
userId: "user-456_test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -628,15 +569,6 @@ describe("endpoint-validator", () => {
|
||||
const longSurveyId = "a".repeat(1000);
|
||||
const longPath = `s/${longSurveyId}`;
|
||||
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
|
||||
|
||||
const longEnvironmentId = "env" + "a".repeat(1000);
|
||||
const longUserId = "user" + "b".repeat(1000);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
|
||||
).toEqual({
|
||||
environmentId: longEnvironmentId,
|
||||
userId: longUserId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty and minimal inputs", () => {
|
||||
@@ -651,7 +583,6 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
expect(isIntegrationRoute("")).toBe(false);
|
||||
expect(isAuthProtectedRoute("")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,14 +43,6 @@ export const isAuthProtectedRoute = (url: string): boolean => {
|
||||
return protectedRoutes.some((route) => url.startsWith(route));
|
||||
};
|
||||
|
||||
export const isSyncWithUserIdentificationEndpoint = (
|
||||
url: string
|
||||
): { environmentId: string; userId: string } | false => {
|
||||
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
|
||||
const match = url.match(regex);
|
||||
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
||||
* Uses whitelist approach - only explicitly allowed routes are accessible
|
||||
|
||||
@@ -161,6 +161,7 @@ checksums:
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||
@@ -248,6 +249,7 @@ checksums:
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
|
||||
common/months: da74749fbe80394fa0f72973d7b0964a
|
||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||
@@ -366,6 +368,7 @@ checksums:
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
common/string: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
|
||||
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
|
||||
common/summary: 13eb7b8a239fb4702dfdaee69100a220
|
||||
@@ -421,6 +424,7 @@ checksums:
|
||||
common/website_and_app_connection: 60fea5cff5bddb4db3c8a1b0a2f9ec63
|
||||
common/website_app_survey: 258579927ed3955dcc8e1cbd7f0df17f
|
||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
@@ -431,6 +435,7 @@ checksums:
|
||||
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
@@ -593,6 +598,12 @@ checksums:
|
||||
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
|
||||
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
||||
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
||||
environments/contacts/attributes_msg_attribute_type_validation_error: ed177ce83bd174ed6be7e889664f93a1
|
||||
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
||||
environments/contacts/attributes_msg_email_or_userid_required: 3be0e745cd3500c9a23bad2e25ad3147
|
||||
environments/contacts/attributes_msg_new_attribute_created: c4c7b27523058f43b70411d7aa6510e5
|
||||
environments/contacts/attributes_msg_userid_already_exists: d2d95ece4b06507be18c9ba240b0a26b
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
@@ -601,6 +612,11 @@ checksums:
|
||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||
environments/contacts/data_type: 1ea127ba2c18d0d91fb0361cc6747e2b
|
||||
environments/contacts/data_type_cannot_be_changed: 22603f6193fdac3784eeef8315df70de
|
||||
environments/contacts/data_type_description: 800bf4935df15e6cb14269e1b60c506e
|
||||
environments/contacts/date_value_required: e0e75b75ae4e8c02f03284954756adc9
|
||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||
@@ -608,13 +624,18 @@ checksums:
|
||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
|
||||
environments/contacts/edit_attributes: a5c3b540441d34b4c0b7faab8f0f0c89
|
||||
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
||||
environments/contacts/number_value_required: d82a198a378eb120f3329e4d3fd4d3f7
|
||||
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
@@ -623,13 +644,22 @@ checksums:
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
environments/contacts/upload_contacts_error_attribute_type_mismatch: 70a60f0886ce767c00defa7d4aad0f93
|
||||
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
|
||||
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
|
||||
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
|
||||
environments/contacts/upload_contacts_error_invalid_file_type: 15ef4fa7c2d5273b05a042f398655e81
|
||||
environments/contacts/upload_contacts_error_no_valid_contacts: 27fbd24ed2d2fa3b6ed7b3a8c1dad343
|
||||
environments/contacts/upload_contacts_modal_attribute_header: 263246ad2a76f8e2f80f0ed175d7629a
|
||||
environments/contacts/upload_contacts_modal_attributes_description: e2cedbd4a043423002cbb2048e2145ac
|
||||
environments/contacts/upload_contacts_modal_attributes_new: 9829382598c681de74130440a37b560f
|
||||
environments/contacts/upload_contacts_modal_attributes_search_or_add: 1874839e465650d353282b43b00247a9
|
||||
environments/contacts/upload_contacts_modal_attributes_should_be_mapped_to: 693dfe5836e90b1c4c7c65b015418174
|
||||
environments/contacts/upload_contacts_modal_attributes_title: 86d0ae6fea0fbb119722ed3841f8385a
|
||||
environments/contacts/upload_contacts_modal_csv_column_header: f181add48fb8325efaa40579fe8c343e
|
||||
environments/contacts/upload_contacts_modal_description: 41566d40d25cc882aa9f82d87b4e2f03
|
||||
environments/contacts/upload_contacts_modal_download_example_csv: 7a186fc4941b264452ee6c9e785769de
|
||||
environments/contacts/upload_contacts_modal_duplicates_description: 112ce4641088520469a83a0bd740b073
|
||||
@@ -796,6 +826,40 @@ checksums:
|
||||
environments/segments/no_attributes_yet: 57beecc917dcd598ccdd0ccfb364a960
|
||||
environments/segments/no_filters_yet: d885a68516840e15dd27f1c17d9a8975
|
||||
environments/segments/no_segments_yet: 6307a4163a5bd553bb2aba074d24be9c
|
||||
environments/segments/operator_contains: 06dd606c0a8f81f9a03b414e9ae89440
|
||||
environments/segments/operator_does_not_contain: 854da2bdf10613ce62fb454bab16c58b
|
||||
environments/segments/operator_ends_with: 2bd866369766c6a2ef74bb9fa74b1d7e
|
||||
environments/segments/operator_is_after: f9d9296eb9a5a7d168cc4e65a4095a87
|
||||
environments/segments/operator_is_before: 2462480cf4e8d2832b64004fbd463e55
|
||||
environments/segments/operator_is_between: 41ff45044d8a017a8a74f72be57916b8
|
||||
environments/segments/operator_is_newer_than: c41e03366623caed6b2c224e50387614
|
||||
environments/segments/operator_is_not_set: 906801489132487ef457652af4835142
|
||||
environments/segments/operator_is_older_than: acca6b309da507bbc5973c4b56b698b0
|
||||
environments/segments/operator_is_same_day: c06506b6eb9f6491f15685baccd68897
|
||||
environments/segments/operator_is_set: 9850468156356f95884bbaf56b6687aa
|
||||
environments/segments/operator_starts_with: 37e55e9080c84a1855956161e7885c21
|
||||
environments/segments/operator_title_contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/segments/operator_title_does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
|
||||
environments/segments/operator_title_ends_with: c8a5f60f1bd1d8fa018dbbf49806fb5b
|
||||
environments/segments/operator_title_equals: 73439e2839b8049e68079b1b6f2e3c41
|
||||
environments/segments/operator_title_greater_equal: 556b342cee0ac7055171e41be80f49e4
|
||||
environments/segments/operator_title_greater_than: e06dabbbf3a9c527502c997101edab40
|
||||
environments/segments/operator_title_is_after: bd4cf644e442fca330cb483528485e5f
|
||||
environments/segments/operator_title_is_before: a47ce3825c5c7cea7ed7eb8d5505a2d5
|
||||
environments/segments/operator_title_is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
|
||||
environments/segments/operator_title_is_newer_than: 133731671413c702a55cdfb9134d63f8
|
||||
environments/segments/operator_title_is_not_set: c1a6fd89387686d3a5426a768bb286e9
|
||||
environments/segments/operator_title_is_older_than: 9064cd482f2312c8b10aee4937d0278d
|
||||
environments/segments/operator_title_is_same_day: 9340bf7bd6ab504d71b0e957ca9fcf4c
|
||||
environments/segments/operator_title_is_set: 1c66019bd162201db83aef305ab2a161
|
||||
environments/segments/operator_title_less_equal: 235dbef60cd0af5ff1d319aab24a1109
|
||||
environments/segments/operator_title_less_than: e9f3c9742143760b28bf4e326f63a97b
|
||||
environments/segments/operator_title_not_equals: a186482f46739c9fe8683826a1cab723
|
||||
environments/segments/operator_title_starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/segments/operator_title_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_title_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/operator_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/person_and_attributes: 507023d577326a6326dd9603dcdc589d
|
||||
environments/segments/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/segments/please_remove_the_segment_from_these_surveys_in_order_to_delete_it: 1858a8ae40bed3a8c06c3bb518e0b8aa
|
||||
@@ -820,6 +884,7 @@ checksums:
|
||||
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
|
||||
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
|
||||
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
|
||||
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
|
||||
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
|
||||
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
|
||||
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
|
||||
@@ -1971,12 +2036,12 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
|
||||
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
|
||||
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
|
||||
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
|
||||
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||
@@ -2039,6 +2104,7 @@ checksums:
|
||||
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
||||
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
||||
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
||||
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
|
||||
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
||||
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
||||
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
||||
|
||||
@@ -167,6 +167,12 @@ export const createEnvironment = async (
|
||||
description: "Your contact's last name",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
name: "Language",
|
||||
description: "The language preference of a contact",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Inputs
|
||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||
inputBorderRadius: 8,
|
||||
inputHeight: 40,
|
||||
inputHeight: 20,
|
||||
inputFontSize: 14,
|
||||
inputPaddingX: 16,
|
||||
inputPaddingY: 16,
|
||||
inputPaddingX: 8,
|
||||
inputPaddingY: 8,
|
||||
inputPlaceholderOpacity: 0.5,
|
||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
@@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
|
||||
*
|
||||
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
|
||||
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
|
||||
*
|
||||
* When loading v4.6 data the new fields are absent. Without this helper the
|
||||
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
|
||||
* colour), causing a visible mismatch. This function derives the new fields
|
||||
* from the actually-saved legacy fields so the preview and form stay coherent.
|
||||
*
|
||||
* Only sets a field when the legacy source exists AND the new field is absent.
|
||||
*/
|
||||
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
|
||||
const light = (key: string): string | undefined =>
|
||||
(saved[key] as { light?: string } | null | undefined)?.light;
|
||||
|
||||
const q = light("questionColor");
|
||||
const b = light("brandColor");
|
||||
const i = light("inputColor");
|
||||
|
||||
return {
|
||||
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
|
||||
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
|
||||
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
|
||||
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
|
||||
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
|
||||
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
|
||||
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
|
||||
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
|
||||
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
|
||||
...(b &&
|
||||
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a complete TProjectStyling object from a single brand color.
|
||||
*
|
||||
|
||||
@@ -168,6 +168,7 @@ export const mockContactAttributeKey: TContactAttributeKey = {
|
||||
type: "custom",
|
||||
description: "mock action class",
|
||||
isUnique: false,
|
||||
dataType: "string",
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
|
||||
@@ -142,7 +142,8 @@ describe("Time Utilities", () => {
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("should not convert dates in contactAttributes", () => {
|
||||
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
@@ -151,13 +152,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.contactAttributes.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("should not convert dates in variables", () => {
|
||||
test("should not convert dates in variables when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
updatedAt: "2024-03-20T15:30:00",
|
||||
variables: {
|
||||
@@ -166,13 +168,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.variables.userId).toBe("123");
|
||||
});
|
||||
|
||||
test("should not convert dates in data or meta", () => {
|
||||
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
data: {
|
||||
@@ -183,10 +186,23 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
|
||||
});
|
||||
|
||||
test("should recurse into all keys when keysToIgnore is not provided", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,18 +151,17 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
|
||||
return [formattedDate, formattedTime].join(seperator);
|
||||
};
|
||||
|
||||
export const convertDatesInObject = <T>(obj: T): T => {
|
||||
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj; // Return if obj is not an object
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// Handle arrays by mapping each element through the function
|
||||
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
|
||||
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
|
||||
}
|
||||
const newObj: any = {};
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (keysToIgnore.has(key)) {
|
||||
if (keysToIgnore?.has(key)) {
|
||||
newObj[key] = obj[key];
|
||||
continue;
|
||||
}
|
||||
@@ -173,10 +172,10 @@ export const convertDatesInObject = <T>(obj: T): T => {
|
||||
) {
|
||||
newObj[key] = new Date(obj[key] as unknown as string);
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
newObj[key] = convertDatesInObject(obj[key]);
|
||||
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
|
||||
} else {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
return newObj as T;
|
||||
};
|
||||
|
||||
@@ -11,3 +11,16 @@ export const isSafeIdentifier = (value: string): boolean => {
|
||||
// Can only contain lowercase letters, numbers, and underscores
|
||||
return /^[a-z0-9_]+$/.test(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a snake_case string to Title Case for display as a label.
|
||||
* Example: "job_description" -> "Job Description"
|
||||
* "api_key" -> "Api Key"
|
||||
* "signup_date" -> "Signup Date"
|
||||
*/
|
||||
export const formatSnakeCaseToTitleCase = (key: string): string => {
|
||||
return key
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
@@ -12,11 +12,18 @@ export function validateInputs<T extends ValidationPair<any>[]>(
|
||||
for (const [value, schema] of pairs) {
|
||||
const inputValidation = schema.safeParse(value);
|
||||
if (!inputValidation.success) {
|
||||
const zodDetails = inputValidation.error.issues
|
||||
.map((issue) => {
|
||||
const path = issue?.path?.join(".") ?? "";
|
||||
return `${path}${issue.message}`;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
logger.error(
|
||||
inputValidation.error,
|
||||
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
|
||||
);
|
||||
throw new ValidationError("Validation failed");
|
||||
throw new ValidationError(`Validation failed: ${zodDetails}`);
|
||||
}
|
||||
parsedData.push(inputValidation.data);
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
"delete": "Löschen",
|
||||
"description": "Beschreibung",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
"months": "Monate",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Website & App Verbindung",
|
||||
"website_app_survey": "Website- & App-Umfrage",
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Projekt nicht gefunden",
|
||||
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
|
||||
"workspaces": "Projekte",
|
||||
"years": "Jahre",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
|
||||
"attribute_value": "Wert",
|
||||
"attribute_value_placeholder": "Attributwert",
|
||||
"attributes_msg_attribute_limit_exceeded": "Es konnten {count} neue Attribute nicht erstellt werden, da dies das maximale Limit von {limit} Attributklassen überschreiten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (Attribut '{key}' hat dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Die E-Mail existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder userId ist erforderlich. Die bestehenden Werte wurden beibehalten.",
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut '{key}' mit Typ '{dataType}' erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die userId existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Schlüssel erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
"custom_attributes": "Benutzerdefinierte Attribute",
|
||||
"data_type": "Datentyp",
|
||||
"data_type_cannot_be_changed": "Der Datentyp kann nach der Erstellung nicht mehr geändert werden",
|
||||
"data_type_description": "Wähle aus, wie dieses Attribut gespeichert und gefiltert werden soll",
|
||||
"date_value_required": "Ein Datumswert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen, wenn du kein Datum festlegen möchtest.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
||||
"edit_attribute_values": "Attribute bearbeiten",
|
||||
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
|
||||
"edit_attributes": "Attribute bearbeiten",
|
||||
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
|
||||
"generate_personal_link": "Persönlichen Link generieren",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
|
||||
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
|
||||
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
|
||||
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
|
||||
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
|
||||
"no_published_surveys": "Keine veröffentlichten Umfragen",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"not_provided": "Nicht angegeben",
|
||||
"number_value_required": "Zahlenwert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen.",
|
||||
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
|
||||
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
|
||||
"personal_survey_link": "Link zur persönlichen Umfrage",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Kontakt suchen",
|
||||
"select_a_survey": "Wähle eine Umfrage aus",
|
||||
"select_attribute": "Attribut auswählen",
|
||||
"select_attribute_key": "Attributschlüssel auswählen",
|
||||
"system_attributes": "Systemattribute",
|
||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribut \"{key}\" ist als \"{dataType}\" definiert, aber die CSV-Datei enthält ungültige Werte: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Doppelte Zuordnungen für folgende Attribute gefunden: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Dateigröße überschreitet das maximale Limit von 800KB",
|
||||
"upload_contacts_error_generic": "Beim Hochladen der Kontakte ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
|
||||
"upload_contacts_error_invalid_file_type": "Bitte lade eine CSV-Datei hoch",
|
||||
"upload_contacts_error_no_valid_contacts": "Die hochgeladene CSV-Datei enthält keine gültigen Kontakte. Bitte schaue dir die Beispiel-CSV-Datei für das richtige Format an.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks-Attribut",
|
||||
"upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
|
||||
"upload_contacts_modal_attributes_new": "Neues Attribut",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Attribut suchen oder hinzufügen",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "sollte zugeordnet werden zu",
|
||||
"upload_contacts_modal_attributes_title": "Attribute",
|
||||
"upload_contacts_modal_csv_column_header": "CSV-Spalte",
|
||||
"upload_contacts_modal_description": "Lade eine CSV hoch, um Kontakte mit Attributen schnell zu importieren",
|
||||
"upload_contacts_modal_download_example_csv": "Beispiel-CSV herunterladen",
|
||||
"upload_contacts_modal_duplicates_description": "Wie sollen wir vorgehen, wenn ein Kontakt bereits existiert?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Noch keine Attribute",
|
||||
"no_filters_yet": "Es gibt noch keine Filter",
|
||||
"no_segments_yet": "Du hast momentan keine gespeicherten Segmente.",
|
||||
"operator_contains": "enthält",
|
||||
"operator_does_not_contain": "enthält nicht",
|
||||
"operator_ends_with": "endet mit",
|
||||
"operator_is_after": "ist nach",
|
||||
"operator_is_before": "ist vor",
|
||||
"operator_is_between": "ist zwischen",
|
||||
"operator_is_newer_than": "ist neuer als",
|
||||
"operator_is_not_set": "ist nicht festgelegt",
|
||||
"operator_is_older_than": "ist älter als",
|
||||
"operator_is_same_day": "ist am selben Tag",
|
||||
"operator_is_set": "ist festgelegt",
|
||||
"operator_starts_with": "fängt an mit",
|
||||
"operator_title_contains": "Enthält",
|
||||
"operator_title_does_not_contain": "Enthält nicht",
|
||||
"operator_title_ends_with": "Endet mit",
|
||||
"operator_title_equals": "Gleich",
|
||||
"operator_title_greater_equal": "Größer als oder gleich",
|
||||
"operator_title_greater_than": "Größer als",
|
||||
"operator_title_is_after": "Ist nach",
|
||||
"operator_title_is_before": "Ist vor",
|
||||
"operator_title_is_between": "Ist zwischen",
|
||||
"operator_title_is_newer_than": "Ist neuer als",
|
||||
"operator_title_is_not_set": "Ist nicht festgelegt",
|
||||
"operator_title_is_older_than": "Ist älter als",
|
||||
"operator_title_is_same_day": "Ist am selben Tag",
|
||||
"operator_title_is_set": "Ist festgelegt",
|
||||
"operator_title_less_equal": "Kleiner oder gleich",
|
||||
"operator_title_less_than": "Kleiner als",
|
||||
"operator_title_not_equals": "Ist nicht gleich",
|
||||
"operator_title_starts_with": "Fängt an mit",
|
||||
"operator_title_user_is_in": "Nutzer ist in",
|
||||
"operator_title_user_is_not_in": "Nutzer ist nicht in",
|
||||
"operator_user_is_in": "Nutzer ist in",
|
||||
"operator_user_is_not_in": "Nutzer ist nicht in",
|
||||
"person_and_attributes": "Person & Attribute",
|
||||
"phone": "Handy",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
|
||||
"value_cannot_be_empty": "Wert darf nicht leer sein.",
|
||||
"value_must_be_a_number": "Wert muss eine Zahl sein.",
|
||||
"value_must_be_positive": "Wert muss eine positive Zahl sein.",
|
||||
"view_filters": "Filter anzeigen",
|
||||
"where": "Wo",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
|
||||
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
|
||||
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
|
||||
"advanced_styling_field_height": "Höhe",
|
||||
"advanced_styling_field_height": "Mindesthöhe",
|
||||
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
|
||||
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
|
||||
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
|
||||
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
|
||||
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
|
||||
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
|
||||
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
|
||||
"suggest_colors": "Farben vorschlagen",
|
||||
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
|
||||
"theme": "Theme",
|
||||
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
|
||||
},
|
||||
|
||||
@@ -39,14 +39,14 @@
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Create an account",
|
||||
"email_does_not_match": "Ooops! Wrong email \uD83E\uDD26",
|
||||
"email_does_not_match": "Ooops! Wrong email 🤦",
|
||||
"email_does_not_match_description": "The email in the invitation does not match yours.",
|
||||
"go_to_app": "Go to app",
|
||||
"happy_to_have_you": "Happy to have you \uD83E\uDD17",
|
||||
"happy_to_have_you": "Happy to have you 🤗",
|
||||
"happy_to_have_you_description": "Please create an account or login.",
|
||||
"invite_expired": "Invite expired \uD83D\uDE25",
|
||||
"invite_expired": "Invite expired 😥",
|
||||
"invite_expired_description": "Invites are valid for 7 days. Please request a new invite.",
|
||||
"invite_not_found": "Invite not found \uD83D\uDE25",
|
||||
"invite_not_found": "Invite not found 😥",
|
||||
"invite_not_found_description": "The invitation code cannot be found or has already been used.",
|
||||
"login": "Login",
|
||||
"welcome_to_organization": "You are in \uD83C\uDF89",
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Workspace not found",
|
||||
"workspace_permission_not_found": "Workspace permission not found",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -520,7 +525,7 @@
|
||||
"text_variable": "Text variable",
|
||||
"verification_email_click_on_this_link": "You can also click on this link:",
|
||||
"verification_email_heading": "Almost there!",
|
||||
"verification_email_hey": "Hey \uD83D\uDC4B",
|
||||
"verification_email_hey": "Hey 👋",
|
||||
"verification_email_if_expired_request_new_token": "If it has expired please request a new token here:",
|
||||
"verification_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"verification_email_request_new_verification": "Request new verification",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_value": "Value",
|
||||
"attribute_value_placeholder": "Attribute Value",
|
||||
"attributes_msg_attribute_limit_exceeded": "Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attribute '{key}' has dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "The email already exists for this environment and was not updated.",
|
||||
"attributes_msg_email_or_userid_required": "Either email or userId is required. The existing values were preserved.",
|
||||
"attributes_msg_new_attribute_created": "Created new attribute '{key}' with type '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "The userId already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Create Key",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"custom_attributes": "Custom Attributes",
|
||||
"data_type": "Data Type",
|
||||
"data_type_cannot_be_changed": "Data type cannot be changed after creation",
|
||||
"data_type_description": "Choose how this attribute should be stored and filtered",
|
||||
"date_value_required": "Date value is required. Use the delete button to remove this attribute if you don't want to set a date.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts’ data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
|
||||
"edit_attributes": "Edit Attributes",
|
||||
"edit_attributes_success": "Contact attributes updated successfully",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
"invalid_date_format": "Invalid date format. Please use a valid date.",
|
||||
"invalid_number_format": "Invalid number format. Please enter a valid number.",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
"not_provided": "Not provided",
|
||||
"number_value_required": "Number value is required. Use the delete button to remove this attribute.",
|
||||
"personal_link_generated": "Personal link generated successfully",
|
||||
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Search contact",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"select_attribute_key": "Select attribute key",
|
||||
"system_attributes": "System Attributes",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribute \"{key}\" is typed as \"{dataType}\" but CSV contains invalid values: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Duplicate mappings found for the following attributes: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "File size exceeds the maximum limit of 800KB",
|
||||
"upload_contacts_error_generic": "An error occurred while uploading the contacts. Please try again later.",
|
||||
"upload_contacts_error_invalid_file_type": "Please upload a CSV file",
|
||||
"upload_contacts_error_no_valid_contacts": "The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks Attribute",
|
||||
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "New attribute",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Search or add attribute",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "should be mapped to",
|
||||
"upload_contacts_modal_attributes_title": "Attributes",
|
||||
"upload_contacts_modal_csv_column_header": "CSV Column",
|
||||
"upload_contacts_modal_description": "Upload a CSV to quickly import contacts with attributes",
|
||||
"upload_contacts_modal_download_example_csv": "Download example CSV",
|
||||
"upload_contacts_modal_duplicates_description": "How should we handle if a contact already exists in your contacts?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "No attributes yet!",
|
||||
"no_filters_yet": "There are no filters yet!",
|
||||
"no_segments_yet": "You currently have no saved segments.",
|
||||
"operator_contains": "contains",
|
||||
"operator_does_not_contain": "does not contain",
|
||||
"operator_ends_with": "ends with",
|
||||
"operator_is_after": "is after",
|
||||
"operator_is_before": "is before",
|
||||
"operator_is_between": "is between",
|
||||
"operator_is_newer_than": "is newer than",
|
||||
"operator_is_not_set": "is not set",
|
||||
"operator_is_older_than": "is older than",
|
||||
"operator_is_same_day": "is same day",
|
||||
"operator_is_set": "is set",
|
||||
"operator_starts_with": "starts with",
|
||||
"operator_title_contains": "Contains",
|
||||
"operator_title_does_not_contain": "Does not contain",
|
||||
"operator_title_ends_with": "Ends with",
|
||||
"operator_title_equals": "Equals",
|
||||
"operator_title_greater_equal": "Greater than or equal to",
|
||||
"operator_title_greater_than": "Greater than",
|
||||
"operator_title_is_after": "Is after",
|
||||
"operator_title_is_before": "Is before",
|
||||
"operator_title_is_between": "Is between",
|
||||
"operator_title_is_newer_than": "Is newer than",
|
||||
"operator_title_is_not_set": "Is not set",
|
||||
"operator_title_is_older_than": "Is older than",
|
||||
"operator_title_is_same_day": "Is same day",
|
||||
"operator_title_is_set": "Is set",
|
||||
"operator_title_less_equal": "Less than or equal to",
|
||||
"operator_title_less_than": "Less than",
|
||||
"operator_title_not_equals": "Not equals to",
|
||||
"operator_title_starts_with": "Starts with",
|
||||
"operator_title_user_is_in": "User is in",
|
||||
"operator_title_user_is_not_in": "User is not in",
|
||||
"operator_user_is_in": "User is in",
|
||||
"operator_user_is_not_in": "User is not in",
|
||||
"person_and_attributes": "Person & Attributes",
|
||||
"phone": "Phone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Please remove the segment from these surveys in order to delete it.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
|
||||
"value_cannot_be_empty": "Value cannot be empty.",
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"value_must_be_positive": "Value must be a positive number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
@@ -956,13 +1021,13 @@
|
||||
"enterprise_features": "Enterprise Features",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_status": "License Status",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Status of your enterprise license.",
|
||||
"license_status_expired": "Expired",
|
||||
"license_status_invalid": "Invalid License",
|
||||
"license_status_unreachable": "Unreachable",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "No call needed, no strings attached: Request a free 30-day trial license to test all features by filling out this form:",
|
||||
"no_credit_card_no_sales_call_just_test_it": "No credit card. No sales call. Just test it :)",
|
||||
@@ -1585,7 +1650,7 @@
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
"survey_placement": "Survey Placement",
|
||||
"survey_trigger": "Survey Trigger",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started \uD83D\uDC49",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
|
||||
"target_block_not_found": "Target block not found",
|
||||
"targeted": "Targeted",
|
||||
"ten_points": "10 points",
|
||||
@@ -2003,7 +2068,7 @@
|
||||
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
|
||||
"how_to_setup": "How to setup",
|
||||
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
|
||||
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
|
||||
"receiving_data": "Receiving data 💃🕺",
|
||||
"recheck": "Re-check",
|
||||
"sdk_connection_details": "SDK Connection Details",
|
||||
"sdk_connection_details_description": "Your unique environment ID and SDK connection URL for integrating Formbricks with your application.",
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Scales the headline text.",
|
||||
"advanced_styling_field_headline_weight": "Headline Font Weight",
|
||||
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
|
||||
"advanced_styling_field_height": "Height",
|
||||
"advanced_styling_field_height": "Minimum Height",
|
||||
"advanced_styling_field_indicator_bg": "Indicator Background",
|
||||
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
|
||||
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
||||
"advanced_styling_field_input_height_description": "Controls the input field height.",
|
||||
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
|
||||
"styling_updated_successfully": "Styling updated successfully",
|
||||
"suggest_colors": "Suggest colors",
|
||||
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
|
||||
"theme": "Theme",
|
||||
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
|
||||
},
|
||||
@@ -2261,7 +2327,7 @@
|
||||
"setup": {
|
||||
"intro": {
|
||||
"get_started": "Get started",
|
||||
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
|
||||
"made_with_love_in_kiel": "Made with 🤍 in Germany",
|
||||
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
|
||||
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
|
||||
"paragraph_3": "We are committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
@@ -2411,7 +2477,7 @@
|
||||
"churn_survey_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We would love to keep you as a customer. Happy to offer a 30% discount for the next year.</span></p>",
|
||||
"churn_survey_question_4_headline": "What features are you missing?",
|
||||
"churn_survey_question_5_button_label": "Send email to CEO",
|
||||
"churn_survey_question_5_headline": "So sorry to hear \uD83D\uDE14 Talk to our CEO directly!",
|
||||
"churn_survey_question_5_headline": "So sorry to hear 😔 Talk to our CEO directly!",
|
||||
"churn_survey_question_5_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.</span></p>",
|
||||
"collect_feedback_description": "Gather comprehensive feedback on your product or service.",
|
||||
"collect_feedback_name": "Collect Feedback",
|
||||
@@ -2539,8 +2605,8 @@
|
||||
"default_welcome_card_html": "Thanks for providing your feedback - let’s go!",
|
||||
"docs_feedback_description": "Measure how clear each page of your developer documentation is.",
|
||||
"docs_feedback_name": "Docs Feedback",
|
||||
"docs_feedback_question_1_choice_1": "Yes \uD83D\uDC4D",
|
||||
"docs_feedback_question_1_choice_2": "No \uD83D\uDC4E",
|
||||
"docs_feedback_question_1_choice_1": "Yes 👍",
|
||||
"docs_feedback_question_1_choice_2": "No 👎",
|
||||
"docs_feedback_question_1_headline": "Was this page helpful?",
|
||||
"docs_feedback_question_2_headline": "Please elaborate:",
|
||||
"docs_feedback_question_3_headline": "Page URL",
|
||||
@@ -2670,7 +2736,7 @@
|
||||
"file_upload": "File Upload",
|
||||
"file_upload_description": "Enable respondents to upload documents, images, or other files",
|
||||
"finish": "Finish",
|
||||
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey \uD83D\uDC4B</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
|
||||
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
|
||||
"free_text": "Free text",
|
||||
"free_text_description": "Collect open-ended feedback",
|
||||
"free_text_placeholder": "Type your answer here…",
|
||||
@@ -2708,7 +2774,7 @@
|
||||
"identify_sign_up_barriers_question_8_placeholder": "Type your answer here…",
|
||||
"identify_sign_up_barriers_question_9_button_label": "Sign Up",
|
||||
"identify_sign_up_barriers_question_9_headline": "Thanks! Here is your code: SIGNUPNOW10",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Thanks a lot for taking the time to share feedback \uD83D\uDE4F</span></p>",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Thanks a lot for taking the time to share feedback 🙏</span></p>",
|
||||
"identify_upsell_opportunities_description": "Find out how much time your product saves your user. Use it to upsell.",
|
||||
"identify_upsell_opportunities_name": "Identify Upsell Opportunities",
|
||||
"identify_upsell_opportunities_question_1_choice_1": "Less than 1 hour",
|
||||
@@ -3037,7 +3103,7 @@
|
||||
"review_prompt_question_1_lower_label": "Not good",
|
||||
"review_prompt_question_1_upper_label": "Very satisfied",
|
||||
"review_prompt_question_2_button_label": "Write review",
|
||||
"review_prompt_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"review_prompt_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"review_prompt_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"review_prompt_question_3_button_label": "Send",
|
||||
"review_prompt_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
@@ -3080,7 +3146,7 @@
|
||||
"smileys_survey_question_1_lower_label": "Not good",
|
||||
"smileys_survey_question_1_upper_label": "Very satisfied",
|
||||
"smileys_survey_question_2_button_label": "Write review",
|
||||
"smileys_survey_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"smileys_survey_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"smileys_survey_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"smileys_survey_question_3_button_label": "Send",
|
||||
"smileys_survey_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
@@ -3091,7 +3157,7 @@
|
||||
"star_rating_survey_question_1_lower_label": "Extremely dissatisfied",
|
||||
"star_rating_survey_question_1_upper_label": "Extremely satisfied",
|
||||
"star_rating_survey_question_2_button_label": "Write review",
|
||||
"star_rating_survey_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"star_rating_survey_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"star_rating_survey_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"star_rating_survey_question_3_button_label": "Send",
|
||||
"star_rating_survey_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
"delete": "Eliminar",
|
||||
"description": "Descripción",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
|
||||
"months": "meses",
|
||||
"move_down": "Mover hacia abajo",
|
||||
"move_up": "Mover hacia arriba",
|
||||
"multiple_languages": "Múltiples idiomas",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual paso a paso",
|
||||
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
|
||||
"string": "Texto",
|
||||
"styling": "Estilo",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumen",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Conexión de sitio web y aplicación",
|
||||
"website_app_survey": "Encuesta de sitio web y aplicación",
|
||||
"website_survey": "Encuesta de sitio web",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Tarjeta de bienvenida",
|
||||
"workspace_configuration": "Configuración del proyecto",
|
||||
"workspace_created_successfully": "Proyecto creado correctamente",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Proyecto no encontrado",
|
||||
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
|
||||
"workspaces": "Proyectos",
|
||||
"years": "años",
|
||||
"you": "Tú",
|
||||
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo actualizado con éxito",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor del atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "No se pudieron crear {count} atributo(s) nuevo(s) ya que se excedería el límite máximo de {limit} clases de atributos. Los atributos existentes se actualizaron correctamente.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (el atributo '{key}' tiene dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "El email ya existe para este entorno y no se actualizó.",
|
||||
"attributes_msg_email_or_userid_required": "Se requiere email o userId. Se conservaron los valores existentes.",
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo '{key}' con tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "El userId ya existe para este entorno y no se actualizó.",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contact_not_found": "No se ha encontrado dicho contacto",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Crear clave",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||
"custom_attributes": "Atributos personalizados",
|
||||
"data_type": "Tipo de dato",
|
||||
"data_type_cannot_be_changed": "El tipo de dato no se puede cambiar después de la creación",
|
||||
"data_type_description": "Elige cómo debe almacenarse y filtrarse este atributo",
|
||||
"date_value_required": "Se requiere un valor de fecha. Usa el botón de eliminar para quitar este atributo si no quieres establecer una fecha.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Esto eliminará el atributo seleccionado. Se perderán todos los datos de contacto asociados con este atributo.} other {Esto eliminará los atributos seleccionados. Se perderán todos los datos de contacto asociados con estos atributos.}}",
|
||||
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
|
||||
"edit_attribute_values": "Editar atributos",
|
||||
"edit_attribute_values_description": "Cambia los valores de atributos específicos para este contacto.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos del contacto actualizados correctamente",
|
||||
"generate_personal_link": "Generar enlace personal",
|
||||
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nombre(s) de columna CSV no válido(s): {columns}. Los nombres de columna que se convertirán en nuevos atributos solo deben contener letras minúsculas, números y guiones bajos, y deben comenzar con una letra.",
|
||||
"invalid_date_format": "Formato de fecha no válido. Por favor, usa una fecha válida.",
|
||||
"invalid_number_format": "Formato de número no válido. Por favor, introduce un número válido.",
|
||||
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
|
||||
"no_published_surveys": "No hay encuestas publicadas",
|
||||
"no_responses_found": "No se encontraron respuestas",
|
||||
"not_provided": "No proporcionado",
|
||||
"number_value_required": "Se requiere un valor numérico. Usa el botón de eliminar para quitar este atributo.",
|
||||
"personal_link_generated": "Enlace personal generado correctamente",
|
||||
"personal_link_generated_but_clipboard_failed": "Enlace personal generado pero falló al copiar al portapapeles: {url}",
|
||||
"personal_survey_link": "Enlace personal de encuesta",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Buscar contacto",
|
||||
"select_a_survey": "Selecciona una encuesta",
|
||||
"select_attribute": "Seleccionar atributo",
|
||||
"select_attribute_key": "Seleccionar clave de atributo",
|
||||
"system_attributes": "Atributos del sistema",
|
||||
"unlock_contacts_description": "Gestiona contactos y envía encuestas dirigidas",
|
||||
"unlock_contacts_title": "Desbloquea contactos con un plan superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "El atributo \"{key}\" está tipado como \"{dataType}\" pero el CSV contiene valores no válidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Se encontraron mapeos duplicados para los siguientes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "El tamaño del archivo supera el límite máximo de 800 KB",
|
||||
"upload_contacts_error_generic": "Se produjo un error al cargar los contactos. Por favor, inténtalo de nuevo más tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, carga un archivo CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "El archivo CSV cargado no contiene ningún contacto válido, por favor consulta el archivo CSV de ejemplo para ver el formato correcto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo de Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Asigna las columnas de tu CSV a los atributos en Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nuevo atributo",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Buscar o añadir atributo",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "debe asignarse a",
|
||||
"upload_contacts_modal_attributes_title": "Atributos",
|
||||
"upload_contacts_modal_csv_column_header": "Columna CSV",
|
||||
"upload_contacts_modal_description": "Sube un CSV para importar rápidamente contactos con atributos",
|
||||
"upload_contacts_modal_download_example_csv": "Descargar CSV de ejemplo",
|
||||
"upload_contacts_modal_duplicates_description": "¿Cómo deberíamos manejar si un contacto ya existe en tus contactos?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "¡Aún no hay atributos!",
|
||||
"no_filters_yet": "¡Aún no hay filtros!",
|
||||
"no_segments_yet": "Actualmente no tienes segmentos guardados.",
|
||||
"operator_contains": "contiene",
|
||||
"operator_does_not_contain": "no contiene",
|
||||
"operator_ends_with": "termina con",
|
||||
"operator_is_after": "es después de",
|
||||
"operator_is_before": "es antes de",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "es más reciente que",
|
||||
"operator_is_not_set": "no está establecido",
|
||||
"operator_is_older_than": "es más antiguo que",
|
||||
"operator_is_same_day": "es el mismo día",
|
||||
"operator_is_set": "está establecido",
|
||||
"operator_starts_with": "comienza con",
|
||||
"operator_title_contains": "Contiene",
|
||||
"operator_title_does_not_contain": "No contiene",
|
||||
"operator_title_ends_with": "Termina con",
|
||||
"operator_title_equals": "Es igual a",
|
||||
"operator_title_greater_equal": "Mayor o igual que",
|
||||
"operator_title_greater_than": "Mayor que",
|
||||
"operator_title_is_after": "Es después de",
|
||||
"operator_title_is_before": "Es antes de",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "Es más reciente que",
|
||||
"operator_title_is_not_set": "No está establecido",
|
||||
"operator_title_is_older_than": "Es más antiguo que",
|
||||
"operator_title_is_same_day": "Es el mismo día",
|
||||
"operator_title_is_set": "Está establecido",
|
||||
"operator_title_less_equal": "Menor o igual que",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "No es igual a",
|
||||
"operator_title_starts_with": "Comienza con",
|
||||
"operator_title_user_is_in": "El usuario está en",
|
||||
"operator_title_user_is_not_in": "El usuario no está en",
|
||||
"operator_user_is_in": "El usuario está en",
|
||||
"operator_user_is_not_in": "El usuario no está en",
|
||||
"person_and_attributes": "Persona y atributos",
|
||||
"phone": "Teléfono",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, elimina el segmento de estas encuestas para poder borrarlo.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "La segmentación de usuarios actualmente solo está disponible cuando",
|
||||
"value_cannot_be_empty": "El valor no puede estar vacío.",
|
||||
"value_must_be_a_number": "El valor debe ser un número.",
|
||||
"value_must_be_positive": "El valor debe ser un número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Donde",
|
||||
"with_the_formbricks_sdk": "con el SDK de Formbricks"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
|
||||
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
|
||||
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fondo del indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura del campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
|
||||
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
|
||||
"styling_updated_successfully": "Estilo actualizado correctamente",
|
||||
"suggest_colors": "Sugerir colores",
|
||||
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
|
||||
"theme": "Tema",
|
||||
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
"delete": "Supprimer",
|
||||
"description": "Description",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"months": "mois",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Statut",
|
||||
"step_by_step_manual": "Manuel étape par étape",
|
||||
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
|
||||
"string": "Texte",
|
||||
"styling": "Style",
|
||||
"submit": "Soumettre",
|
||||
"summary": "Résumé",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Connexion de sites Web et d'applications",
|
||||
"website_app_survey": "Sondage sur le site Web et l'application",
|
||||
"website_survey": "Sondage de site web",
|
||||
"weeks": "semaines",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"workspace_configuration": "Configuration du projet",
|
||||
"workspace_created_successfully": "Projet créé avec succès",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Projet introuvable",
|
||||
"workspace_permission_not_found": "Permission du projet introuvable",
|
||||
"workspaces": "Projets",
|
||||
"years": "années",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribut mis à jour avec succès",
|
||||
"attribute_value": "Valeur",
|
||||
"attribute_value_placeholder": "Valeur d'attribut",
|
||||
"attributes_msg_attribute_limit_exceeded": "Impossible de créer {count, plural, one {# nouvel attribut} other {# nouveaux attributs}} car cela dépasserait la limite maximale de {limit} classes d'attributs. Les attributs existants ont été mis à jour avec succès.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (l'attribut « {key} » a le type de données : {dataType})",
|
||||
"attributes_msg_email_already_exists": "L'adresse e-mail existe déjà pour cet environnement et n'a pas été mise à jour.",
|
||||
"attributes_msg_email_or_userid_required": "L'adresse e-mail ou l'identifiant utilisateur est requis. Les valeurs existantes ont été conservées.",
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut « {key} » créé avec le type « {dataType} »",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Créer une clé",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||
"custom_attributes": "Attributs personnalisés",
|
||||
"data_type": "Type de données",
|
||||
"data_type_cannot_be_changed": "Le type de données ne peut pas être modifié après la création",
|
||||
"data_type_description": "Choisis comment cet attribut doit être stocké et filtré",
|
||||
"date_value_required": "Une valeur de date est requise. Utilise le bouton supprimer pour retirer cet attribut si tu ne veux pas définir de date.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Cela supprimera l'attribut sélectionné. Toutes les données de contact associées à cet attribut seront perdues.} other {Cela supprimera les attributs sélectionnés. Toutes les données de contact associées à ces attributs seront perdues.}}",
|
||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
|
||||
"edit_attribute_values": "Modifier les attributs",
|
||||
"edit_attribute_values_description": "Modifiez les valeurs d'attributs spécifiques pour ce contact.",
|
||||
"edit_attributes": "Modifier les attributs",
|
||||
"edit_attributes_success": "Attributs du contact mis à jour avec succès",
|
||||
"generate_personal_link": "Générer un lien personnel",
|
||||
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
|
||||
"invalid_csv_column_names": "Nom(s) de colonne CSV invalide(s) : {columns}. Les noms de colonnes qui deviendront de nouveaux attributs ne doivent contenir que des lettres minuscules, des chiffres et des underscores, et doivent commencer par une lettre.",
|
||||
"invalid_date_format": "Format de date invalide. Merci d'utiliser une date valide.",
|
||||
"invalid_number_format": "Format de nombre invalide. Veuillez saisir un nombre valide.",
|
||||
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
|
||||
"no_published_surveys": "Aucune enquête publiée",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"not_provided": "Non fourni",
|
||||
"number_value_required": "La valeur numérique est requise. Utilisez le bouton supprimer pour retirer cet attribut.",
|
||||
"personal_link_generated": "Lien personnel généré avec succès",
|
||||
"personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers : {url}",
|
||||
"personal_survey_link": "Lien vers le sondage personnel",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Rechercher un contact",
|
||||
"select_a_survey": "Sélectionner une enquête",
|
||||
"select_attribute": "Sélectionner un attribut",
|
||||
"select_attribute_key": "Sélectionner une clé d'attribut",
|
||||
"system_attributes": "Attributs système",
|
||||
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
|
||||
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
|
||||
"upload_contacts_error_attribute_type_mismatch": "L'attribut « {key} » est de type « {dataType} » mais le CSV contient des valeurs invalides : {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Mappages en double trouvés pour les attributs suivants : {attributes}",
|
||||
"upload_contacts_error_file_too_large": "La taille du fichier dépasse la limite maximale de 800 Ko",
|
||||
"upload_contacts_error_generic": "Une erreur s'est produite lors de l'importation des contacts. Veuillez réessayer plus tard.",
|
||||
"upload_contacts_error_invalid_file_type": "Veuillez importer un fichier CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Le fichier CSV importé ne contient aucun contact valide, veuillez consulter l'exemple de fichier CSV pour le format correct.",
|
||||
"upload_contacts_modal_attribute_header": "Attribut Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mappez les colonnes de votre CSV aux attributs dans Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nouvel attribut",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Rechercher ou ajouter un attribut",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "devrait être mappé à",
|
||||
"upload_contacts_modal_attributes_title": "Attributs",
|
||||
"upload_contacts_modal_csv_column_header": "Colonne CSV",
|
||||
"upload_contacts_modal_description": "Téléchargez un fichier CSV pour importer rapidement des contacts avec des attributs.",
|
||||
"upload_contacts_modal_download_example_csv": "Télécharger un exemple de CSV",
|
||||
"upload_contacts_modal_duplicates_description": "Que faire si un contact existe déjà ?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Aucun attribut pour le moment !",
|
||||
"no_filters_yet": "Il n'y a pas encore de filtres !",
|
||||
"no_segments_yet": "Aucun segment n'est actuellement enregistré.",
|
||||
"operator_contains": "contient",
|
||||
"operator_does_not_contain": "ne contient pas",
|
||||
"operator_ends_with": "se termine par",
|
||||
"operator_is_after": "est après",
|
||||
"operator_is_before": "est avant",
|
||||
"operator_is_between": "est entre",
|
||||
"operator_is_newer_than": "est plus récent que",
|
||||
"operator_is_not_set": "n'est pas défini",
|
||||
"operator_is_older_than": "est plus ancien que",
|
||||
"operator_is_same_day": "est le même jour",
|
||||
"operator_is_set": "est défini",
|
||||
"operator_starts_with": "commence par",
|
||||
"operator_title_contains": "Contient",
|
||||
"operator_title_does_not_contain": "Ne contient pas",
|
||||
"operator_title_ends_with": "Se termine par",
|
||||
"operator_title_equals": "Égal",
|
||||
"operator_title_greater_equal": "Supérieur ou égal à",
|
||||
"operator_title_greater_than": "Supérieur à",
|
||||
"operator_title_is_after": "Est après",
|
||||
"operator_title_is_before": "Est avant",
|
||||
"operator_title_is_between": "Est entre",
|
||||
"operator_title_is_newer_than": "Est plus récent que",
|
||||
"operator_title_is_not_set": "N'est pas défini",
|
||||
"operator_title_is_older_than": "Est plus ancien que",
|
||||
"operator_title_is_same_day": "Est le même jour",
|
||||
"operator_title_is_set": "Est défini",
|
||||
"operator_title_less_equal": "Inférieur ou égal à",
|
||||
"operator_title_less_than": "Inférieur à",
|
||||
"operator_title_not_equals": "N'est pas égal à",
|
||||
"operator_title_starts_with": "Commence par",
|
||||
"operator_title_user_is_in": "L'utilisateur est dans",
|
||||
"operator_title_user_is_not_in": "L'utilisateur n'est pas dans",
|
||||
"operator_user_is_in": "L'utilisateur est dans",
|
||||
"operator_user_is_not_in": "L'utilisateur n'est pas dans",
|
||||
"person_and_attributes": "Personne et attributs",
|
||||
"phone": "Téléphone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Veuillez supprimer le segment de ces enquêtes afin de le supprimer.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "La ciblage des utilisateurs est actuellement disponible uniquement lorsque",
|
||||
"value_cannot_be_empty": "La valeur ne peut pas être vide.",
|
||||
"value_must_be_a_number": "La valeur doit être un nombre.",
|
||||
"value_must_be_positive": "La valeur doit être un nombre positif.",
|
||||
"view_filters": "Filtres de vue",
|
||||
"where": "Où",
|
||||
"with_the_formbricks_sdk": "avec le SDK Formbricks"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
|
||||
"advanced_styling_field_headline_weight": "Graisse de police du titre",
|
||||
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
|
||||
"advanced_styling_field_height": "Hauteur",
|
||||
"advanced_styling_field_height": "Hauteur minimale",
|
||||
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur du champ de saisie.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
|
||||
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
|
||||
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Afficher la signature « Propulsé par Formbricks »",
|
||||
"styling_updated_successfully": "Style mis à jour avec succès",
|
||||
"suggest_colors": "Suggérer des couleurs",
|
||||
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur « Enregistrer » pour conserver les modifications.",
|
||||
"theme": "Thème",
|
||||
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"date": "Dátum",
|
||||
"days": "napok",
|
||||
"default": "Alapértelmezett",
|
||||
"delete": "Törlés",
|
||||
"description": "Leírás",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
"mobile_overlay_title": "Hoppá, apró képernyő észlelve!",
|
||||
"months": "hónapok",
|
||||
"move_down": "Mozgatás le",
|
||||
"move_up": "Mozgatás fel",
|
||||
"multiple_languages": "Több nyelv",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Állapot",
|
||||
"step_by_step_manual": "Lépésenkénti kézikönyv",
|
||||
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
|
||||
"string": "Szöveg",
|
||||
"styling": "Stíluskészítés",
|
||||
"submit": "Elküldés",
|
||||
"summary": "Összegzés",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Webhely és alkalmazáskapcsolódás",
|
||||
"website_app_survey": "Webhely és alkalmazás-kérdőív",
|
||||
"website_survey": "Webhely kérdőív",
|
||||
"weeks": "hetek",
|
||||
"welcome_card": "Üdvözlő kártya",
|
||||
"workspace_configuration": "Munkaterület beállítása",
|
||||
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "A munkaterület nem található",
|
||||
"workspace_permission_not_found": "A munkaterület-jogosultság nem található",
|
||||
"workspaces": "Munkaterületek",
|
||||
"years": "évek",
|
||||
"you": "Ön",
|
||||
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Az attribútum sikeresen frissítve",
|
||||
"attribute_value": "Érték",
|
||||
"attribute_value_placeholder": "Attribútum értéke",
|
||||
"attributes_msg_attribute_limit_exceeded": "Nem sikerült létrehozni {count} új attribútumot, mivel az meghaladná a maximális {limit} attribútumosztály-korlátot. A meglévő attribútumok sikeresen frissítve lettek.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (a(z) '{key}' attribútum adattípusa: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Az e-mail cím már létezik ebben a környezetben, és nem lett frissítve.",
|
||||
"attributes_msg_email_or_userid_required": "E-mail cím vagy felhasználói azonosító megadása kötelező. A meglévő értékek megmaradtak.",
|
||||
"attributes_msg_new_attribute_created": "Új '{key}' attribútum létrehozva '{dataType}' típussal",
|
||||
"attributes_msg_userid_already_exists": "A felhasználói azonosító már létezik ebben a környezetben, és nem lett frissítve.",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"contact_not_found": "Nem található ilyen partner",
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Kulcs létrehozása",
|
||||
"create_new_attribute": "Új attribútum létrehozása",
|
||||
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
||||
"custom_attributes": "Egyéni attribútumok",
|
||||
"data_type": "Adattípus",
|
||||
"data_type_cannot_be_changed": "Az adattípus létrehozás után nem módosítható",
|
||||
"data_type_description": "Válaszd ki, hogyan legyen tárolva és szűrve ez az attribútum",
|
||||
"date_value_required": "Dátum érték megadása kötelező. Használd a törlés gombot az attribútum eltávolításához, ha nem szeretnél dátumot megadni.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Ez törölni fogja a kiválasztott attribútumot. Az ehhez az attribútumhoz hozzárendelt összes partneradat el fog veszni.} other {Ez törölni fogja a kiválasztott attribútumokat. Az ezekhez az attribútumokhoz hozzárendelt összes partneradat el fog veszni.}}",
|
||||
"delete_contact_confirmation": "Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Ez törölni fogja az ehhez a partnerhez tartozó összes kérdőívválaszt és partnerattribútumot. A partner adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ez a partner olyan válaszokkal rendelkezik, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.} other {Ez törölni fogja az ezekhez a partnerekhez tartozó összes kérdőívválaszt és partnerattribútumot. A partnerek adatain alapuló bármilyen célzás és személyre szabás el fog veszni. Ha ezek a partnerek olyan válaszokkal rendelkeznek, amelyek a kérdőívkvótákba beletartoznak, akkor a kvóta számlálója csökkentve lesz, de a kvóta korlátai változatlanok maradnak.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Az attribútum címkéjének és leírásának frissítése.",
|
||||
"edit_attribute_values": "Attribútumok szerkesztése",
|
||||
"edit_attribute_values_description": "Bizonyos attribútumok értékének megváltoztatása ennél a partnernél.",
|
||||
"edit_attributes": "Attribútumok szerkesztése",
|
||||
"edit_attributes_success": "A partner attribútumai sikeresen frissítve",
|
||||
"generate_personal_link": "Személyes hivatkozás előállítása",
|
||||
"generate_personal_link_description": "Válasszon egy közzétett kérdőívet, hogy személyre szabott hivatkozást állítson elő ehhez a partnerhez.",
|
||||
"invalid_csv_column_names": "Érvénytelen CSV oszlopnév(nevek): {columns}. Az új attribútumokká váló oszlopnevek csak kisbetűket, számokat és aláhúzásjeleket tartalmazhatnak, és betűvel kell kezdődniük.",
|
||||
"invalid_date_format": "Érvénytelen dátumformátum. Kérlek, adj meg egy érvényes dátumot.",
|
||||
"invalid_number_format": "Érvénytelen számformátum. Kérlek, adj meg egy érvényes számot.",
|
||||
"no_published_link_surveys_available": "Nem érhetők el közzétett hivatkozás-kérdőívek. Először tegyen közzé egy hivatkozás-kérdőívet.",
|
||||
"no_published_surveys": "Nincsenek közzétett kérdőívek",
|
||||
"no_responses_found": "Nem találhatók válaszok",
|
||||
"not_provided": "Nincs megadva",
|
||||
"number_value_required": "Szám érték megadása kötelező. Használd a törlés gombot az attribútum eltávolításához.",
|
||||
"personal_link_generated": "A személyes hivatkozás sikeresen előállítva",
|
||||
"personal_link_generated_but_clipboard_failed": "A személyes hivatkozás előállítva, de nem sikerült a vágólapra másolni: {url}",
|
||||
"personal_survey_link": "Személyes kérdőív-hivatkozás",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Partner keresése",
|
||||
"select_a_survey": "Kérdőív kiválasztása",
|
||||
"select_attribute": "Attribútum kiválasztása",
|
||||
"select_attribute_key": "Attribútum kulcs kiválasztása",
|
||||
"system_attributes": "Rendszer attribútumok",
|
||||
"unlock_contacts_description": "Partnerek kezelése és célzott kérdőívek kiküldése",
|
||||
"unlock_contacts_title": "Partnerek feloldása egy magasabb csomaggal",
|
||||
"upload_contacts_error_attribute_type_mismatch": "A(z) \"{key}\" attribútum típusa \"{dataType}\", de a CSV érvénytelen értékeket tartalmaz: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Duplikált leképezések találhatók a következő attribútumokhoz: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "A fájl mérete meghaladja a maximális 800KB-os limitet",
|
||||
"upload_contacts_error_generic": "Hiba történt a kapcsolatok feltöltése során. Kérjük, próbáld újra később.",
|
||||
"upload_contacts_error_invalid_file_type": "Kérjük, tölts fel egy CSV fájlt",
|
||||
"upload_contacts_error_no_valid_contacts": "A feltöltött CSV fájl nem tartalmaz érvényes kapcsolatokat, kérjük, nézd meg a minta CSV fájlt a helyes formátumhoz.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks attribútum",
|
||||
"upload_contacts_modal_attributes_description": "A CSV-ben lévő oszlopok leképezése a Formbricksben lévő attribútumokra.",
|
||||
"upload_contacts_modal_attributes_new": "Új attribútum",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Attribútum keresése vagy hozzáadása",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "le kell képezni erre:",
|
||||
"upload_contacts_modal_attributes_title": "Attribútumok",
|
||||
"upload_contacts_modal_csv_column_header": "CSV oszlop",
|
||||
"upload_contacts_modal_description": "CSV feltöltése a partnerek attribútumokkal együtt történő gyors importálásához",
|
||||
"upload_contacts_modal_download_example_csv": "Példa CSV letöltése",
|
||||
"upload_contacts_modal_duplicates_description": "Hogyan kell kezelnünk, ha egy partner már szerepel a partnerek között?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Még nincsenek attribútumok!",
|
||||
"no_filters_yet": "Még nincsenek szűrők!",
|
||||
"no_segments_yet": "Jelenleg nincsenek mentett szakaszai.",
|
||||
"operator_contains": "tartalmazza",
|
||||
"operator_does_not_contain": "nem tartalmazza",
|
||||
"operator_ends_with": "ezzel végződik",
|
||||
"operator_is_after": "ez után",
|
||||
"operator_is_before": "ez előtt",
|
||||
"operator_is_between": "között",
|
||||
"operator_is_newer_than": "újabb mint",
|
||||
"operator_is_not_set": "nincs beállítva",
|
||||
"operator_is_older_than": "régebbi mint",
|
||||
"operator_is_same_day": "ugyanazon a napon",
|
||||
"operator_is_set": "beállítva",
|
||||
"operator_starts_with": "ezzel kezdődik",
|
||||
"operator_title_contains": "Tartalmazza",
|
||||
"operator_title_does_not_contain": "Nem tartalmazza",
|
||||
"operator_title_ends_with": "Ezzel végződik",
|
||||
"operator_title_equals": "Egyenlő",
|
||||
"operator_title_greater_equal": "Nagyobb vagy egyenlő",
|
||||
"operator_title_greater_than": "Nagyobb mint",
|
||||
"operator_title_is_after": "Ez után",
|
||||
"operator_title_is_before": "Ez előtt",
|
||||
"operator_title_is_between": "Között",
|
||||
"operator_title_is_newer_than": "Újabb mint",
|
||||
"operator_title_is_not_set": "Nincs beállítva",
|
||||
"operator_title_is_older_than": "Régebbi mint",
|
||||
"operator_title_is_same_day": "Ugyanazon a napon",
|
||||
"operator_title_is_set": "Beállítva",
|
||||
"operator_title_less_equal": "Kisebb vagy egyenlő",
|
||||
"operator_title_less_than": "Kisebb mint",
|
||||
"operator_title_not_equals": "Nem egyenlő",
|
||||
"operator_title_starts_with": "Ezzel kezdődik",
|
||||
"operator_title_user_is_in": "A felhasználó benne van",
|
||||
"operator_title_user_is_not_in": "A felhasználó nincs benne",
|
||||
"operator_user_is_in": "A felhasználó benne van",
|
||||
"operator_user_is_not_in": "A felhasználó nincs benne",
|
||||
"person_and_attributes": "Személy és attribútumok",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Távolítsa el a szakaszt ezekből a kérdőívekből, hogy törölhesse azt.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "A felhasználók megcélzása jelenleg csak akkor érhető el, ha",
|
||||
"value_cannot_be_empty": "Az érték nem lehet üres.",
|
||||
"value_must_be_a_number": "Az értékének számnak kell lennie.",
|
||||
"value_must_be_positive": "Az értéknek pozitív számnak kell lennie.",
|
||||
"view_filters": "Szűrők megtekintése",
|
||||
"where": "Ahol",
|
||||
"with_the_formbricks_sdk": "a Formbricks SDK-val"
|
||||
@@ -956,13 +1021,13 @@
|
||||
"enterprise_features": "Vállalati funkciók",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
|
||||
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
|
||||
"license_status": "Licencállapot",
|
||||
"license_status_active": "Aktív",
|
||||
"license_status_description": "A vállalati licenc állapota.",
|
||||
"license_status_expired": "Lejárt",
|
||||
"license_status_invalid": "Érvénytelen licenc",
|
||||
"license_status_unreachable": "Nem érhető el",
|
||||
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
|
||||
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
|
||||
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
|
||||
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
|
||||
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
|
||||
"advanced_styling_field_height": "Magasság",
|
||||
"advanced_styling_field_height": "Minimális magasság",
|
||||
"advanced_styling_field_indicator_bg": "Jelző háttere",
|
||||
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
|
||||
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
|
||||
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
|
||||
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
|
||||
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
|
||||
"styling_updated_successfully": "A stílus sikeresen frissítve",
|
||||
"suggest_colors": "Színek ajánlása",
|
||||
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
|
||||
"theme": "Téma",
|
||||
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
"delete": "削除",
|
||||
"description": "説明",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"months": "ヶ月",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "ステータス",
|
||||
"step_by_step_manual": "ステップバイステップマニュアル",
|
||||
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
|
||||
"string": "テキスト",
|
||||
"styling": "スタイル",
|
||||
"submit": "送信",
|
||||
"summary": "概要",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "ウェブサイト&アプリ接続",
|
||||
"website_app_survey": "ウェブサイト&アプリフォーム",
|
||||
"website_survey": "ウェブサイトフォーム",
|
||||
"weeks": "週間",
|
||||
"welcome_card": "ウェルカムカード",
|
||||
"workspace_configuration": "ワークスペース設定",
|
||||
"workspace_created_successfully": "ワークスペースが正常に作成されました",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "ワークスペースが見つかりません",
|
||||
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
|
||||
"workspaces": "ワークスペース",
|
||||
"years": "年",
|
||||
"you": "あなた",
|
||||
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
|
||||
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "属性を更新しました",
|
||||
"attribute_value": "値",
|
||||
"attribute_value_placeholder": "属性値",
|
||||
"attributes_msg_attribute_limit_exceeded": "最大制限の{limit}個の属性クラスを超えるため、{count}個の新しい属性を作成できませんでした。既存の属性は正常に更新されました。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(属性'{key}'のデータ型: {dataType})",
|
||||
"attributes_msg_email_already_exists": "このメールアドレスはこの環境に既に存在するため、更新されませんでした。",
|
||||
"attributes_msg_email_or_userid_required": "メールアドレスまたはユーザーIDのいずれかが必要です。既存の値は保持されました。",
|
||||
"attributes_msg_new_attribute_created": "新しい属性'{key}'をタイプ'{dataType}'で作成しました",
|
||||
"attributes_msg_userid_already_exists": "このユーザーIDはこの環境に既に存在するため、更新されませんでした。",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contact_not_found": "そのような連絡先は見つかりません",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "キーを作成",
|
||||
"create_new_attribute": "新しい属性を作成",
|
||||
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
||||
"custom_attributes": "カスタム属性",
|
||||
"data_type": "データ型",
|
||||
"data_type_cannot_be_changed": "データ型は作成後に変更できません",
|
||||
"data_type_description": "この属性の保存方法とフィルタリング方法を選択してください",
|
||||
"date_value_required": "日付の値が必要です。日付を設定したくない場合は、削除ボタンを使用してこの属性を削除してください。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
|
||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "この属性のラベルと説明を更新します。",
|
||||
"edit_attribute_values": "属性を編集",
|
||||
"edit_attribute_values_description": "この連絡先の特定の属性の値を変更します。",
|
||||
"edit_attributes": "属性を編集",
|
||||
"edit_attributes_success": "連絡先属性が正常に更新されました",
|
||||
"generate_personal_link": "個人リンクを生成",
|
||||
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
|
||||
"invalid_csv_column_names": "無効なCSV列名: {columns}。新しい属性となる列名は、小文字、数字、アンダースコアのみを含み、文字で始まる必要があります。",
|
||||
"invalid_date_format": "無効な日付形式です。有効な日付を使用してください。",
|
||||
"invalid_number_format": "無効な数値形式です。有効な数値を入力してください。",
|
||||
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
||||
"no_published_surveys": "公開されたフォームはありません",
|
||||
"no_responses_found": "回答が見つかりません",
|
||||
"not_provided": "提供されていません",
|
||||
"number_value_required": "数値が必要です。この属性を削除するには削除ボタンを使用してください。",
|
||||
"personal_link_generated": "個人リンクが正常に生成されました",
|
||||
"personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}",
|
||||
"personal_survey_link": "個人調査リンク",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "連絡先を検索",
|
||||
"select_a_survey": "フォームを選択",
|
||||
"select_attribute": "属性を選択",
|
||||
"select_attribute_key": "属性キーを選択",
|
||||
"system_attributes": "システム属性",
|
||||
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
|
||||
"unlock_contacts_title": "上位プランで連絡先をアンロック",
|
||||
"upload_contacts_error_attribute_type_mismatch": "属性「{key}」は「{dataType}」として型付けされていますが、CSVに無効な値が含まれています:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "次の属性に重複したマッピングが見つかりました:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "ファイルサイズが最大制限の800KBを超えています",
|
||||
"upload_contacts_error_generic": "連絡先のアップロード中にエラーが発生しました。後でもう一度お試しください。",
|
||||
"upload_contacts_error_invalid_file_type": "CSVファイルをアップロードしてください",
|
||||
"upload_contacts_error_no_valid_contacts": "アップロードされたCSVファイルには有効な連絡先が含まれていません。正しい形式についてはサンプルCSVファイルをご確認ください。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks属性",
|
||||
"upload_contacts_modal_attributes_description": "CSVの列をFormbricksの属性にマッピングします。",
|
||||
"upload_contacts_modal_attributes_new": "新しい属性",
|
||||
"upload_contacts_modal_attributes_search_or_add": "属性を検索または追加",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "は以下にマッピングする必要があります",
|
||||
"upload_contacts_modal_attributes_title": "属性",
|
||||
"upload_contacts_modal_csv_column_header": "CSV列",
|
||||
"upload_contacts_modal_description": "CSVをアップロードして、属性を持つ連絡先をすばやくインポート",
|
||||
"upload_contacts_modal_download_example_csv": "CSVの例をダウンロード",
|
||||
"upload_contacts_modal_duplicates_description": "連絡先がすでに存在する場合、どのように処理しますか?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "属性がまだありません!",
|
||||
"no_filters_yet": "フィルターはまだありません!",
|
||||
"no_segments_yet": "保存されたセグメントはまだありません。",
|
||||
"operator_contains": "を含む",
|
||||
"operator_does_not_contain": "を含まない",
|
||||
"operator_ends_with": "で終わる",
|
||||
"operator_is_after": "より後",
|
||||
"operator_is_before": "より前",
|
||||
"operator_is_between": "の間である",
|
||||
"operator_is_newer_than": "より新しい",
|
||||
"operator_is_not_set": "設定されていない",
|
||||
"operator_is_older_than": "より古い",
|
||||
"operator_is_same_day": "同じ日である",
|
||||
"operator_is_set": "設定されている",
|
||||
"operator_starts_with": "で始まる",
|
||||
"operator_title_contains": "を含む",
|
||||
"operator_title_does_not_contain": "を含まない",
|
||||
"operator_title_ends_with": "で終わる",
|
||||
"operator_title_equals": "と等しい",
|
||||
"operator_title_greater_equal": "以上",
|
||||
"operator_title_greater_than": "より大きい",
|
||||
"operator_title_is_after": "より後",
|
||||
"operator_title_is_before": "より前",
|
||||
"operator_title_is_between": "の間である",
|
||||
"operator_title_is_newer_than": "より新しい",
|
||||
"operator_title_is_not_set": "設定されていない",
|
||||
"operator_title_is_older_than": "より古い",
|
||||
"operator_title_is_same_day": "同じ日である",
|
||||
"operator_title_is_set": "設定されている",
|
||||
"operator_title_less_equal": "以下",
|
||||
"operator_title_less_than": "より小さい",
|
||||
"operator_title_not_equals": "等しくない",
|
||||
"operator_title_starts_with": "で始まる",
|
||||
"operator_title_user_is_in": "ユーザーが含まれる",
|
||||
"operator_title_user_is_not_in": "ユーザーが含まれない",
|
||||
"operator_user_is_in": "ユーザーが含まれる",
|
||||
"operator_user_is_not_in": "ユーザーが含まれない",
|
||||
"person_and_attributes": "人物と属性",
|
||||
"phone": "電話",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "このセグメントを削除するには、まず以下のフォームから外してください。",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "ユーザーターゲティングは現在、利用条件を満たす場合のみ利用可能です",
|
||||
"value_cannot_be_empty": "値は空にできません。",
|
||||
"value_must_be_a_number": "値は数値である必要があります。",
|
||||
"value_must_be_positive": "値は正の数である必要があります。",
|
||||
"view_filters": "フィルターを表示",
|
||||
"where": "条件",
|
||||
"with_the_formbricks_sdk": "Formbricks SDK を利用して"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
|
||||
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
|
||||
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
|
||||
"advanced_styling_field_height": "高さ",
|
||||
"advanced_styling_field_height": "最小の高さ",
|
||||
"advanced_styling_field_indicator_bg": "インジケーターの背景",
|
||||
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
|
||||
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
|
||||
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
|
||||
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
|
||||
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
|
||||
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
|
||||
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
|
||||
"styling_updated_successfully": "スタイルを正常に更新しました",
|
||||
"suggest_colors": "カラーを提案",
|
||||
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
|
||||
"theme": "テーマ",
|
||||
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
"delete": "Verwijderen",
|
||||
"description": "Beschrijving",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
|
||||
"months": "maanden",
|
||||
"move_down": "Ga naar beneden",
|
||||
"move_up": "Ga omhoog",
|
||||
"multiple_languages": "Meerdere talen",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Stap voor stap handleiding",
|
||||
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
|
||||
"string": "Tekst",
|
||||
"styling": "Styling",
|
||||
"submit": "Indienen",
|
||||
"summary": "Samenvatting",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Website- en app-verbinding",
|
||||
"website_app_survey": "Website- en app-enquête",
|
||||
"website_survey": "Website-enquête",
|
||||
"weeks": "weken",
|
||||
"welcome_card": "Welkomstkaart",
|
||||
"workspace_configuration": "Werkruimte-configuratie",
|
||||
"workspace_created_successfully": "Project succesvol aangemaakt",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Werkruimte niet gevonden",
|
||||
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
|
||||
"workspaces": "Werkruimtes",
|
||||
"years": "jaren",
|
||||
"you": "Jij",
|
||||
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
|
||||
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attribuut succesvol bijgewerkt",
|
||||
"attribute_value": "Waarde",
|
||||
"attribute_value_placeholder": "Attribuutwaarde",
|
||||
"attributes_msg_attribute_limit_exceeded": "Kon {count} nieuwe attribu(u)t(en) niet aanmaken omdat dit de maximale limiet van {limit} attribuutklassen zou overschrijden. Bestaande attributen zijn succesvol bijgewerkt.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attribuut '{key}' heeft dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Het e-mailadres bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"attributes_msg_email_or_userid_required": "E-mailadres of userId is vereist. De bestaande waarden zijn behouden.",
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut '{key}' aangemaakt met type '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "De userId bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contact_not_found": "Er is geen dergelijk contact gevonden",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Sleutel aanmaken",
|
||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
||||
"custom_attributes": "Aangepaste kenmerken",
|
||||
"data_type": "Gegevenstype",
|
||||
"data_type_cannot_be_changed": "Gegevenstype kan niet worden gewijzigd na aanmaak",
|
||||
"data_type_description": "Kies hoe dit attribuut moet worden opgeslagen en gefilterd",
|
||||
"date_value_required": "Datumwaarde is vereist. Gebruik de verwijderknop om dit attribuut te verwijderen als je geen datum wilt instellen.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dit verwijdert het geselecteerde attribuut. Alle contactgegevens die aan dit attribuut zijn gekoppeld, gaan verloren.} other {Dit verwijdert de geselecteerde attributen. Alle contactgegevens die aan deze attributen zijn gekoppeld, gaan verloren.}}",
|
||||
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Werk het label en de beschrijving voor dit attribuut bij.",
|
||||
"edit_attribute_values": "Attributen bewerken",
|
||||
"edit_attribute_values_description": "Wijzig de waarden voor specifieke attributen voor dit contact.",
|
||||
"edit_attributes": "Attributen bewerken",
|
||||
"edit_attributes_success": "Contactattributen succesvol bijgewerkt",
|
||||
"generate_personal_link": "Persoonlijke link genereren",
|
||||
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
|
||||
"invalid_csv_column_names": "Ongeldige CSV-kolomna(a)m(en): {columns}. Kolomnamen die nieuwe kenmerken worden, mogen alleen kleine letters, cijfers en underscores bevatten en moeten beginnen met een letter.",
|
||||
"invalid_date_format": "Ongeldig datumformaat. Gebruik een geldige datum.",
|
||||
"invalid_number_format": "Ongeldig getalformaat. Voer een geldig getal in.",
|
||||
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
|
||||
"no_published_surveys": "Geen gepubliceerde enquêtes",
|
||||
"no_responses_found": "Geen reacties gevonden",
|
||||
"not_provided": "Niet voorzien",
|
||||
"number_value_required": "Getalwaarde is verplicht. Gebruik de verwijderknop om dit attribuut te verwijderen.",
|
||||
"personal_link_generated": "Persoonlijke link succesvol gegenereerd",
|
||||
"personal_link_generated_but_clipboard_failed": "Persoonlijke link gegenereerd maar kopiëren naar klembord mislukt: {url}",
|
||||
"personal_survey_link": "Persoonlijke enquêtelink",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Zoek contactpersoon",
|
||||
"select_a_survey": "Selecteer een enquête",
|
||||
"select_attribute": "Selecteer Kenmerk",
|
||||
"select_attribute_key": "Selecteer kenmerksleutel",
|
||||
"system_attributes": "Systeemkenmerken",
|
||||
"unlock_contacts_description": "Beheer contacten en verstuur gerichte enquêtes",
|
||||
"unlock_contacts_title": "Ontgrendel contacten met een hoger abonnement",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribuut \"{key}\" is getypeerd als \"{dataType}\" maar CSV bevat ongeldige waarden: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Dubbele koppelingen gevonden voor de volgende attributen: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Bestandsgrootte overschrijdt de maximale limiet van 800KB",
|
||||
"upload_contacts_error_generic": "Er is een fout opgetreden bij het uploaden van de contacten. Probeer het later opnieuw.",
|
||||
"upload_contacts_error_invalid_file_type": "Upload een CSV-bestand",
|
||||
"upload_contacts_error_no_valid_contacts": "Het geüploade CSV-bestand bevat geen geldige contacten, zie het voorbeeld CSV-bestand voor het juiste formaat.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks attribuut",
|
||||
"upload_contacts_modal_attributes_description": "Wijs de kolommen in uw CSV toe aan de attributen in Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nieuw attribuut",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Kenmerk zoeken of toevoegen",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "in kaart moeten worden gebracht",
|
||||
"upload_contacts_modal_attributes_title": "Kenmerken",
|
||||
"upload_contacts_modal_csv_column_header": "CSV kolom",
|
||||
"upload_contacts_modal_description": "Upload een CSV om snel contacten met attributen te importeren",
|
||||
"upload_contacts_modal_download_example_csv": "Voorbeeld-CSV downloaden",
|
||||
"upload_contacts_modal_duplicates_description": "Hoe moeten we omgaan als er al een contact bestaat in uw contacten?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Nog geen attributen!",
|
||||
"no_filters_yet": "Er zijn nog geen filters!",
|
||||
"no_segments_yet": "Je hebt momenteel geen opgeslagen segmenten.",
|
||||
"operator_contains": "bevat",
|
||||
"operator_does_not_contain": "bevat niet",
|
||||
"operator_ends_with": "eindigt met",
|
||||
"operator_is_after": "is na",
|
||||
"operator_is_before": "is eerder",
|
||||
"operator_is_between": "is tussen",
|
||||
"operator_is_newer_than": "is nieuwer dan",
|
||||
"operator_is_not_set": "is niet ingesteld",
|
||||
"operator_is_older_than": "is ouder dan",
|
||||
"operator_is_same_day": "is dezelfde dag",
|
||||
"operator_is_set": "is ingesteld",
|
||||
"operator_starts_with": "begint met",
|
||||
"operator_title_contains": "Bevat",
|
||||
"operator_title_does_not_contain": "Bevat niet",
|
||||
"operator_title_ends_with": "Eindigt met",
|
||||
"operator_title_equals": "Gelijk aan",
|
||||
"operator_title_greater_equal": "Groter dan of gelijk aan",
|
||||
"operator_title_greater_than": "Groter dan",
|
||||
"operator_title_is_after": "Is na",
|
||||
"operator_title_is_before": "Is eerder",
|
||||
"operator_title_is_between": "Is tussen",
|
||||
"operator_title_is_newer_than": "Is nieuwer dan",
|
||||
"operator_title_is_not_set": "Is niet ingesteld",
|
||||
"operator_title_is_older_than": "Is ouder dan",
|
||||
"operator_title_is_same_day": "Is dezelfde dag",
|
||||
"operator_title_is_set": "Is ingesteld",
|
||||
"operator_title_less_equal": "Kleiner dan of gelijk aan",
|
||||
"operator_title_less_than": "Kleiner dan",
|
||||
"operator_title_not_equals": "Is niet gelijk aan",
|
||||
"operator_title_starts_with": "Begint met",
|
||||
"operator_title_user_is_in": "Gebruiker zit in",
|
||||
"operator_title_user_is_not_in": "Gebruiker zit niet in",
|
||||
"operator_user_is_in": "Gebruiker zit in",
|
||||
"operator_user_is_not_in": "Gebruiker zit niet in",
|
||||
"person_and_attributes": "Persoon & attributen",
|
||||
"phone": "Telefoon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Verwijder het segment uit deze enquêtes om het te kunnen verwijderen.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Gebruikerstargeting is momenteel alleen beschikbaar wanneer",
|
||||
"value_cannot_be_empty": "Waarde kan niet leeg zijn.",
|
||||
"value_must_be_a_number": "Waarde moet een getal zijn.",
|
||||
"value_must_be_positive": "Waarde moet een positief getal zijn.",
|
||||
"view_filters": "Bekijk filters",
|
||||
"where": "Waar",
|
||||
"with_the_formbricks_sdk": "met de Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
|
||||
"advanced_styling_field_headline_weight": "Letterdikte kop",
|
||||
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
|
||||
"advanced_styling_field_height": "Hoogte",
|
||||
"advanced_styling_field_height": "Minimale hoogte",
|
||||
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
|
||||
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
|
||||
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
|
||||
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
|
||||
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
|
||||
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
|
||||
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
|
||||
"styling_updated_successfully": "Styling succesvol bijgewerkt",
|
||||
"suggest_colors": "Kleuren voorstellen",
|
||||
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
|
||||
"theme": "Thema",
|
||||
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
"delete": "Apagar",
|
||||
"description": "Descrição",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"months": "meses",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "status",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
|
||||
"string": "Texto",
|
||||
"styling": "Estilização",
|
||||
"submit": "Enviar",
|
||||
"summary": "Resumo",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Conexão de Site e App",
|
||||
"website_app_survey": "Pesquisa de Site e App",
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Projeto não encontrado",
|
||||
"workspace_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor do atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (atributo '{key}' tem dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "O e-mail já existe para este ambiente e não foi atualizado.",
|
||||
"attributes_msg_email_or_userid_required": "E-mail ou userId é obrigatório. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Novo atributo '{key}' criado com tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Criar chave",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
"custom_attributes": "Atributos personalizados",
|
||||
"data_type": "Tipo de dados",
|
||||
"data_type_cannot_be_changed": "O tipo de dados não pode ser alterado após a criação",
|
||||
"data_type_description": "Escolha como este atributo deve ser armazenado e filtrado",
|
||||
"date_value_required": "O valor da data é obrigatório. Use o botão excluir para remover este atributo se você não quiser definir uma data.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Isso excluirá o atributo selecionado. Todos os dados de contato associados a este atributo serão perdidos.} other {Isso excluirá os atributos selecionados. Todos os dados de contato associados a estes atributos serão perdidos.}}",
|
||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||
"edit_attribute_values": "Editar atributos",
|
||||
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contato.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos do contato atualizados com sucesso",
|
||||
"generate_personal_link": "Gerar link pessoal",
|
||||
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e sublinhados, e devem começar com uma letra.",
|
||||
"invalid_date_format": "Formato de data inválido. Por favor, use uma data válida.",
|
||||
"invalid_number_format": "Formato de número inválido. Por favor, insira um número válido.",
|
||||
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
|
||||
"no_published_surveys": "Sem pesquisas publicadas",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"number_value_required": "O valor numérico é obrigatório. Use o botão excluir para remover este atributo.",
|
||||
"personal_link_generated": "Link pessoal gerado com sucesso",
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link da pesquisa pessoal",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Buscar contato",
|
||||
"select_a_survey": "Selecione uma pesquisa",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
"select_attribute_key": "Selecionar chave de atributo",
|
||||
"system_attributes": "Atributos do sistema",
|
||||
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
|
||||
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está tipado como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Mapeamentos duplicados encontrados para os seguintes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "O tamanho do arquivo excede o limite máximo de 800KB",
|
||||
"upload_contacts_error_generic": "Ocorreu um erro ao fazer upload dos contatos. Por favor, tente novamente mais tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, faça upload de um arquivo CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "O arquivo CSV enviado não contém nenhum contato válido, por favor veja o arquivo CSV de exemplo para o formato correto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo do Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapeie as colunas do seu CSV para os atributos no Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Novo atributo",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Buscar ou adicionar atributo",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "deve ser mapeado para",
|
||||
"upload_contacts_modal_attributes_title": "Atributos",
|
||||
"upload_contacts_modal_csv_column_header": "Coluna CSV",
|
||||
"upload_contacts_modal_description": "Faça upload de um CSV para importar contatos com atributos rapidamente",
|
||||
"upload_contacts_modal_download_example_csv": "Baixar exemplo de CSV",
|
||||
"upload_contacts_modal_duplicates_description": "O que devemos fazer se um contato já existir nos seus contatos?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Ainda não tem atributos!",
|
||||
"no_filters_yet": "Ainda não tem filtros!",
|
||||
"no_segments_yet": "Você não tem segmentos salvos no momento.",
|
||||
"operator_contains": "contém",
|
||||
"operator_does_not_contain": "não contém",
|
||||
"operator_ends_with": "termina com",
|
||||
"operator_is_after": "é depois",
|
||||
"operator_is_before": "é antes",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "é mais recente que",
|
||||
"operator_is_not_set": "não está definido",
|
||||
"operator_is_older_than": "é mais antigo que",
|
||||
"operator_is_same_day": "é no mesmo dia",
|
||||
"operator_is_set": "está definido",
|
||||
"operator_starts_with": "começa com",
|
||||
"operator_title_contains": "Contém",
|
||||
"operator_title_does_not_contain": "Não contém",
|
||||
"operator_title_ends_with": "Termina com",
|
||||
"operator_title_equals": "Igual",
|
||||
"operator_title_greater_equal": "Maior ou igual a",
|
||||
"operator_title_greater_than": "Maior que",
|
||||
"operator_title_is_after": "É depois",
|
||||
"operator_title_is_before": "É antes",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "É mais recente que",
|
||||
"operator_title_is_not_set": "Não está definido",
|
||||
"operator_title_is_older_than": "É mais antigo que",
|
||||
"operator_title_is_same_day": "É no mesmo dia",
|
||||
"operator_title_is_set": "Está definido",
|
||||
"operator_title_less_equal": "Menor ou igual a",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "Diferente de",
|
||||
"operator_title_starts_with": "Começa com",
|
||||
"operator_title_user_is_in": "Usuário está em",
|
||||
"operator_title_user_is_not_in": "Usuário não está em",
|
||||
"operator_user_is_in": "Usuário está em",
|
||||
"operator_user_is_not_in": "Usuário não está em",
|
||||
"person_and_attributes": "Pessoa & Atributos",
|
||||
"phone": "Celular",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento dessas pesquisas para deletá-lo.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "A segmentação de usuários está disponível apenas quando",
|
||||
"value_cannot_be_empty": "O valor não pode estar vazio.",
|
||||
"value_must_be_a_number": "O valor deve ser um número.",
|
||||
"value_must_be_positive": "O valor deve ser um número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Onde",
|
||||
"with_the_formbricks_sdk": "com o SDK do Formbricks."
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
||||
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
|
||||
"theme": "Tema",
|
||||
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
"delete": "Eliminar",
|
||||
"description": "Descrição",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"months": "meses",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Estado",
|
||||
"step_by_step_manual": "Manual passo a passo",
|
||||
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
|
||||
"string": "Texto",
|
||||
"styling": "Estilo",
|
||||
"submit": "Submeter",
|
||||
"summary": "Resumo",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Ligação de Website e Aplicação",
|
||||
"website_app_survey": "Inquérito do Website e da Aplicação",
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weeks": "semanas",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"workspace_configuration": "Configuração do projeto",
|
||||
"workspace_created_successfully": "Projeto criado com sucesso",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Projeto não encontrado",
|
||||
"workspace_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"workspaces": "Projetos",
|
||||
"years": "anos",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"attribute_value": "Valor",
|
||||
"attribute_value_placeholder": "Valor do atributo",
|
||||
"attributes_msg_attribute_limit_exceeded": "Não foi possível criar {count} novo(s) atributo(s), pois excederia o limite máximo de {limit} classes de atributos. Os atributos existentes foram atualizados com sucesso.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (o atributo '{key}' tem dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "O email já existe para este ambiente e não foi atualizado.",
|
||||
"attributes_msg_email_or_userid_required": "É necessário email ou userId. Os valores existentes foram preservados.",
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo '{key}' com tipo '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "O userId já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Criar chave",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
"custom_attributes": "Atributos personalizados",
|
||||
"data_type": "Tipo de dados",
|
||||
"data_type_cannot_be_changed": "O tipo de dados não pode ser alterado após a criação",
|
||||
"data_type_description": "Escolhe como este atributo deve ser armazenado e filtrado",
|
||||
"date_value_required": "O valor da data é obrigatório. Usa o botão eliminar para remover este atributo se não quiseres definir uma data.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Isto irá eliminar o atributo selecionado. Todos os dados de contacto associados a este atributo serão perdidos.} other {Isto irá eliminar os atributos selecionados. Todos os dados de contacto associados a estes atributos serão perdidos.}}",
|
||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||
"edit_attribute_values": "Editar atributos",
|
||||
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contacto.",
|
||||
"edit_attributes": "Editar atributos",
|
||||
"edit_attributes_success": "Atributos do contacto atualizados com sucesso",
|
||||
"generate_personal_link": "Gerar Link Pessoal",
|
||||
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
|
||||
"invalid_csv_column_names": "Nome(s) de coluna CSV inválido(s): {columns}. Os nomes de colunas que se tornarão novos atributos devem conter apenas letras minúsculas, números e underscores, e devem começar com uma letra.",
|
||||
"invalid_date_format": "Formato de data inválido. Por favor, usa uma data válida.",
|
||||
"invalid_number_format": "Formato de número inválido. Por favor, introduz um número válido.",
|
||||
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
|
||||
"no_published_surveys": "Sem inquéritos publicados",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"not_provided": "Não fornecido",
|
||||
"number_value_required": "O valor numérico é obrigatório. Usa o botão eliminar para remover este atributo.",
|
||||
"personal_link_generated": "Link pessoal gerado com sucesso",
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link do inquérito pessoal",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Procurar contacto",
|
||||
"select_a_survey": "Selecione um inquérito",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
"select_attribute_key": "Selecionar chave de atributo",
|
||||
"system_attributes": "Atributos do sistema",
|
||||
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
|
||||
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
|
||||
"upload_contacts_error_attribute_type_mismatch": "O atributo \"{key}\" está definido como \"{dataType}\", mas o CSV contém valores inválidos: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Foram encontrados mapeamentos duplicados para os seguintes atributos: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "O tamanho do ficheiro excede o limite máximo de 800KB",
|
||||
"upload_contacts_error_generic": "Ocorreu um erro ao carregar os contactos. Por favor, tenta novamente mais tarde.",
|
||||
"upload_contacts_error_invalid_file_type": "Por favor, carrega um ficheiro CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "O ficheiro CSV carregado não contém contactos válidos, por favor consulta o ficheiro CSV de exemplo para o formato correto.",
|
||||
"upload_contacts_modal_attribute_header": "Atributo Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapeie as colunas no seu CSV para os atributos no Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Novo atributo",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Pesquisar ou adicionar atributo",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "deve ser mapeado para",
|
||||
"upload_contacts_modal_attributes_title": "Atributos",
|
||||
"upload_contacts_modal_csv_column_header": "Coluna CSV",
|
||||
"upload_contacts_modal_description": "Carregue um ficheiro CSV para importar rapidamente contactos com atributos",
|
||||
"upload_contacts_modal_download_example_csv": "Descarregar exemplo de CSV",
|
||||
"upload_contacts_modal_duplicates_description": "Como devemos proceder se um contacto já existir nos seus contactos?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Ainda não há atributos!",
|
||||
"no_filters_yet": "Ainda não há filtros!",
|
||||
"no_segments_yet": "Atualmente, não tem segmentos guardados.",
|
||||
"operator_contains": "contém",
|
||||
"operator_does_not_contain": "não contém",
|
||||
"operator_ends_with": "termina com",
|
||||
"operator_is_after": "é depois",
|
||||
"operator_is_before": "é antes",
|
||||
"operator_is_between": "está entre",
|
||||
"operator_is_newer_than": "é mais recente que",
|
||||
"operator_is_not_set": "não está definido",
|
||||
"operator_is_older_than": "é mais antigo que",
|
||||
"operator_is_same_day": "é no mesmo dia",
|
||||
"operator_is_set": "está definido",
|
||||
"operator_starts_with": "começa com",
|
||||
"operator_title_contains": "Contém",
|
||||
"operator_title_does_not_contain": "Não contém",
|
||||
"operator_title_ends_with": "Termina com",
|
||||
"operator_title_equals": "Igual",
|
||||
"operator_title_greater_equal": "Maior ou igual a",
|
||||
"operator_title_greater_than": "Maior que",
|
||||
"operator_title_is_after": "É depois",
|
||||
"operator_title_is_before": "É antes",
|
||||
"operator_title_is_between": "Está entre",
|
||||
"operator_title_is_newer_than": "É mais recente que",
|
||||
"operator_title_is_not_set": "Não está definido",
|
||||
"operator_title_is_older_than": "É mais antigo que",
|
||||
"operator_title_is_same_day": "É no mesmo dia",
|
||||
"operator_title_is_set": "Está definido",
|
||||
"operator_title_less_equal": "Menor ou igual a",
|
||||
"operator_title_less_than": "Menor que",
|
||||
"operator_title_not_equals": "Diferente de",
|
||||
"operator_title_starts_with": "Começa com",
|
||||
"operator_title_user_is_in": "O utilizador está em",
|
||||
"operator_title_user_is_not_in": "O utilizador não está em",
|
||||
"operator_user_is_in": "O utilizador está em",
|
||||
"operator_user_is_not_in": "O utilizador não está em",
|
||||
"person_and_attributes": "Pessoa e Atributos",
|
||||
"phone": "Telefone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Por favor, remova o segmento destes questionários para o eliminar.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "A segmentação de utilizadores está atualmente disponível apenas quando",
|
||||
"value_cannot_be_empty": "O valor não pode estar vazio.",
|
||||
"value_must_be_a_number": "O valor deve ser um número.",
|
||||
"value_must_be_positive": "O valor deve ser um número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Onde",
|
||||
"with_the_formbricks_sdk": "com o SDK Formbricks"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
|
||||
"advanced_styling_field_headline_weight": "Peso da fonte do título",
|
||||
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
|
||||
"advanced_styling_field_height": "Altura",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"advanced_styling_field_indicator_bg": "Fundo do indicador",
|
||||
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
|
||||
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
|
||||
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
|
||||
"styling_updated_successfully": "Estilo atualizado com sucesso",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
|
||||
"theme": "Tema",
|
||||
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
"delete": "Șterge",
|
||||
"description": "Descriere",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"months": "luni",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Stare",
|
||||
"step_by_step_manual": "Manual pas cu pas",
|
||||
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
|
||||
"string": "Text",
|
||||
"styling": "Stilizare",
|
||||
"submit": "Trimite",
|
||||
"summary": "Sumar",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Conectare site web și aplicație",
|
||||
"website_app_survey": "Chestionar pentru site și aplicație",
|
||||
"website_survey": "Chestionar despre site",
|
||||
"weeks": "săptămâni",
|
||||
"welcome_card": "Card de bun venit",
|
||||
"workspace_configuration": "Configurare workspace",
|
||||
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Workspace-ul nu a fost găsit",
|
||||
"workspace_permission_not_found": "Permisiunea pentru workspace nu a fost găsită",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "ani",
|
||||
"you": "Tu",
|
||||
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Atribut actualizat cu succes",
|
||||
"attribute_value": "Valoare",
|
||||
"attribute_value_placeholder": "Valoare atribut",
|
||||
"attributes_msg_attribute_limit_exceeded": "Nu s-au putut crea {count, plural, one {1 atribut nou} few {# atribute noi} other {# de atribute noi}} deoarece s-ar depăși limita maximă de {limit, plural, one {1 clasă de atribute} few {# clase de atribute} other {# de clase de atribute}}. Atributele existente au fost actualizate cu succes.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (atributul „{key}” are dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Emailul există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"attributes_msg_email_or_userid_required": "Este necesar fie un email, fie un userId. Valorile existente au fost păstrate.",
|
||||
"attributes_msg_new_attribute_created": "A fost creat atributul nou „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "UserId-ul există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contact_not_found": "Nu a fost găsit niciun contact",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Creează cheie",
|
||||
"create_new_attribute": "Creează atribut nou",
|
||||
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
||||
"custom_attributes": "Atribute personalizate",
|
||||
"data_type": "Tip de date",
|
||||
"data_type_cannot_be_changed": "Tipul de date nu poate fi schimbat după creare",
|
||||
"data_type_description": "Alege cum să fie stocat și filtrat acest atribut",
|
||||
"date_value_required": "Valoarea pentru dată este obligatorie. Folosește butonul de ștergere dacă nu vrei să setezi o dată.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Acest lucru va șterge atributul selectat. Orice date de contact asociate cu acest atribut vor fi pierdute.} few {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.} other {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.}}",
|
||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Actualizează eticheta și descrierea acestui atribut.",
|
||||
"edit_attribute_values": "Editează atributele",
|
||||
"edit_attribute_values_description": "Modifică valorile anumitor atribute pentru acest contact.",
|
||||
"edit_attributes": "Editează atributele",
|
||||
"edit_attributes_success": "Atributele contactului au fost actualizate cu succes",
|
||||
"generate_personal_link": "Generează link personal",
|
||||
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
|
||||
"invalid_csv_column_names": "Nume de coloană CSV nevalide: {columns}. Numele coloanelor care vor deveni atribute noi trebuie să conțină doar litere mici, cifre și caractere de subliniere și trebuie să înceapă cu o literă.",
|
||||
"invalid_date_format": "Format de dată invalid. Te rugăm să folosești o dată validă.",
|
||||
"invalid_number_format": "Format de număr invalid. Te rugăm să introduci un număr valid.",
|
||||
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
|
||||
"no_published_surveys": "Nu există sondaje publicate",
|
||||
"no_responses_found": "Nu s-au găsit răspunsuri",
|
||||
"not_provided": "Nu a fost furnizat",
|
||||
"number_value_required": "Valoarea numerică este obligatorie. Folosește butonul de ștergere pentru a elimina acest atribut.",
|
||||
"personal_link_generated": "Linkul personal a fost generat cu succes",
|
||||
"personal_link_generated_but_clipboard_failed": "Linkul personal a fost generat, dar nu s-a reușit copierea în clipboard: {url}",
|
||||
"personal_survey_link": "Link către sondajul personal",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Căutați contact",
|
||||
"select_a_survey": "Selectați un sondaj",
|
||||
"select_attribute": "Selectează atributul",
|
||||
"select_attribute_key": "Selectează cheia atributului",
|
||||
"system_attributes": "Atribute de sistem",
|
||||
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
|
||||
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Atributul „{key}” este de tipul „{dataType}”, dar CSV-ul conține valori invalide: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Au fost găsite mapări duplicate pentru următoarele atribute: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Dimensiunea fișierului depășește limita maximă de 800KB",
|
||||
"upload_contacts_error_generic": "A apărut o eroare la încărcarea contactelor. Te rugăm să încerci din nou mai târziu.",
|
||||
"upload_contacts_error_invalid_file_type": "Te rugăm să încarci un fișier CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Fișierul CSV încărcat nu conține contacte valide. Consultă fișierul CSV de exemplu pentru formatul corect.",
|
||||
"upload_contacts_modal_attribute_header": "Atribut Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Atribut nou",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Căutați sau adăugați atribut",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "ar trebui să fie mapat către",
|
||||
"upload_contacts_modal_attributes_title": "Atribute",
|
||||
"upload_contacts_modal_csv_column_header": "Coloană CSV",
|
||||
"upload_contacts_modal_description": "Încărcați un fișier CSV pentru a importa rapid contactele cu atribute.",
|
||||
"upload_contacts_modal_download_example_csv": "Descărcați exemplul CSV",
|
||||
"upload_contacts_modal_duplicates_description": "Cum ar trebui să procedăm dacă un contact există deja în agenda dumneavoastră?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Niciun atribut încă!",
|
||||
"no_filters_yet": "Nu există filtre încă!",
|
||||
"no_segments_yet": "În prezent nu aveți segmente salvate.",
|
||||
"operator_contains": "conține",
|
||||
"operator_does_not_contain": "nu conține",
|
||||
"operator_ends_with": "se termină cu",
|
||||
"operator_is_after": "este după",
|
||||
"operator_is_before": "este înainte",
|
||||
"operator_is_between": "este între",
|
||||
"operator_is_newer_than": "este mai nou decât",
|
||||
"operator_is_not_set": "nu este setat",
|
||||
"operator_is_older_than": "este mai vechi decât",
|
||||
"operator_is_same_day": "este în aceeași zi",
|
||||
"operator_is_set": "este setat",
|
||||
"operator_starts_with": "începe cu",
|
||||
"operator_title_contains": "Conține",
|
||||
"operator_title_does_not_contain": "Nu conține",
|
||||
"operator_title_ends_with": "Se termină cu",
|
||||
"operator_title_equals": "Egal",
|
||||
"operator_title_greater_equal": "Mai mare sau egal cu",
|
||||
"operator_title_greater_than": "Mai mare decât",
|
||||
"operator_title_is_after": "Este după",
|
||||
"operator_title_is_before": "Este înainte",
|
||||
"operator_title_is_between": "Este între",
|
||||
"operator_title_is_newer_than": "Este mai nou decât",
|
||||
"operator_title_is_not_set": "Nu este setat",
|
||||
"operator_title_is_older_than": "Este mai vechi decât",
|
||||
"operator_title_is_same_day": "Este în aceeași zi",
|
||||
"operator_title_is_set": "Este setat",
|
||||
"operator_title_less_equal": "Mai mic sau egal cu",
|
||||
"operator_title_less_than": "Mai mic decât",
|
||||
"operator_title_not_equals": "Nu este egal cu",
|
||||
"operator_title_starts_with": "Începe cu",
|
||||
"operator_title_user_is_in": "Utilizatorul este în",
|
||||
"operator_title_user_is_not_in": "Utilizatorul nu este în",
|
||||
"operator_user_is_in": "Utilizatorul este în",
|
||||
"operator_user_is_not_in": "Utilizatorul nu este în",
|
||||
"person_and_attributes": "Persoană & Atribute",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vă rugăm să eliminați segmentul din aceste chestionare pentru a-l șterge.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Targetarea utilizatorilor este disponibilă în prezent doar atunci când",
|
||||
"value_cannot_be_empty": "Valoarea nu poate fi goală.",
|
||||
"value_must_be_a_number": "Valoarea trebuie să fie un număr.",
|
||||
"value_must_be_positive": "Valoarea trebuie să fie un număr pozitiv.",
|
||||
"view_filters": "Vizualizați filtrele",
|
||||
"where": "Unde",
|
||||
"with_the_formbricks_sdk": "cu SDK Formbricks"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
|
||||
"advanced_styling_field_headline_weight": "Grosime font titlu",
|
||||
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
|
||||
"advanced_styling_field_height": "Înălțime",
|
||||
"advanced_styling_field_height": "Înălțime minimă",
|
||||
"advanced_styling_field_indicator_bg": "Fundal indicator",
|
||||
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
|
||||
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
|
||||
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
|
||||
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
|
||||
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
|
||||
"suggest_colors": "Sugerează culori",
|
||||
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apasă pe „Salvează” pentru a păstra modificările.",
|
||||
"theme": "Temă",
|
||||
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
"delete": "Удалить",
|
||||
"description": "Описание",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
|
||||
"months": "месяцы",
|
||||
"move_down": "Переместить вниз",
|
||||
"move_up": "Переместить вверх",
|
||||
"multiple_languages": "Несколько языков",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Статус",
|
||||
"step_by_step_manual": "Пошаговая инструкция",
|
||||
"storage_not_configured": "Хранилище файлов не настроено, загрузка, скорее всего, не удастся",
|
||||
"string": "Текст",
|
||||
"styling": "Стилизация",
|
||||
"submit": "Отправить",
|
||||
"summary": "Сводка",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Связь сайта и приложения",
|
||||
"website_app_survey": "Опрос сайта и приложения",
|
||||
"website_survey": "Опрос сайта",
|
||||
"weeks": "недели",
|
||||
"welcome_card": "Приветственная карточка",
|
||||
"workspace_configuration": "Настройка рабочего пространства",
|
||||
"workspace_created_successfully": "Рабочий проект успешно создан",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Рабочее пространство не найдено",
|
||||
"workspace_permission_not_found": "Разрешение на рабочее пространство не найдено",
|
||||
"workspaces": "Рабочие пространства",
|
||||
"years": "годы",
|
||||
"you": "Вы",
|
||||
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Атрибут успешно обновлён",
|
||||
"attribute_value": "Значение",
|
||||
"attribute_value_placeholder": "Значение атрибута",
|
||||
"attributes_msg_attribute_limit_exceeded": "Не удалось создать {count} новых атрибута, так как это превысит максимальное количество классов атрибутов: {limit}. Существующие атрибуты были успешно обновлены.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (атрибут «{key}» имеет тип данных: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Этот email уже существует в данной среде и не был обновлён.",
|
||||
"attributes_msg_email_or_userid_required": "Требуется указать либо email, либо userId. Существующие значения были сохранены.",
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот userId уже существует в данной среде и не был обновлён.",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contact_not_found": "Такой контакт не найден",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Создать ключ",
|
||||
"create_new_attribute": "Создать новый атрибут",
|
||||
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
||||
"custom_attributes": "Пользовательские атрибуты",
|
||||
"data_type": "Тип данных",
|
||||
"data_type_cannot_be_changed": "Тип данных нельзя изменить после создания",
|
||||
"data_type_description": "Выберите, как этот атрибут будет храниться и фильтроваться",
|
||||
"date_value_required": "Требуется значение даты. Используйте кнопку удаления, если не хотите указывать дату.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Будет удалён выбранный атрибут. Все данные контактов, связанные с этим атрибутом, будут потеряны.} few {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} many {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} other {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.}}",
|
||||
"delete_contact_confirmation": "Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
|
||||
"edit_attribute_values": "Редактировать атрибуты",
|
||||
"edit_attribute_values_description": "Измените значения определённых атрибутов для этого контакта.",
|
||||
"edit_attributes": "Редактировать атрибуты",
|
||||
"edit_attributes_success": "Атрибуты контакта успешно обновлены",
|
||||
"generate_personal_link": "Сгенерировать персональную ссылку",
|
||||
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
|
||||
"invalid_csv_column_names": "Недопустимые имена столбцов в CSV: {columns}. Имена столбцов, которые станут новыми атрибутами, должны содержать только строчные буквы, цифры и подчёркивания, а также начинаться с буквы.",
|
||||
"invalid_date_format": "Неверный формат даты. Пожалуйста, используйте корректную дату.",
|
||||
"invalid_number_format": "Неверный формат числа. Пожалуйста, введите корректное число.",
|
||||
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
|
||||
"no_published_surveys": "Нет опубликованных опросов",
|
||||
"no_responses_found": "Ответы не найдены",
|
||||
"not_provided": "Не указано",
|
||||
"number_value_required": "Требуется числовое значение. Используй кнопку удаления, чтобы убрать этот атрибут.",
|
||||
"personal_link_generated": "Персональная ссылка успешно сгенерирована",
|
||||
"personal_link_generated_but_clipboard_failed": "Персональная ссылка сгенерирована, но не удалось скопировать в буфер обмена: {url}",
|
||||
"personal_survey_link": "Персональная ссылка на опрос",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Поиск контакта",
|
||||
"select_a_survey": "Выберите опрос",
|
||||
"select_attribute": "Выберите атрибут",
|
||||
"select_attribute_key": "Выберите ключ атрибута",
|
||||
"system_attributes": "Системные атрибуты",
|
||||
"unlock_contacts_description": "Управляйте контактами и отправляйте целевые опросы",
|
||||
"unlock_contacts_title": "Откройте доступ к контактам с более высоким тарифом",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Атрибут «{key}» имеет тип «{dataType}», но в CSV обнаружены некорректные значения: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Обнаружены дублирующиеся сопоставления для следующих атрибутов: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Размер файла превышает максимальный лимит 800 КБ",
|
||||
"upload_contacts_error_generic": "Произошла ошибка при загрузке контактов. Пожалуйста, попробуй ещё раз позже.",
|
||||
"upload_contacts_error_invalid_file_type": "Пожалуйста, загрузи файл в формате CSV",
|
||||
"upload_contacts_error_no_valid_contacts": "Загруженный CSV-файл не содержит ни одного корректного контакта. Ознакомься с примером CSV-файла для правильного формата.",
|
||||
"upload_contacts_modal_attribute_header": "Атрибут Formbricks",
|
||||
"upload_contacts_modal_attributes_description": "Сопоставьте столбцы в вашем CSV с атрибутами в Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Новый атрибут",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Найти или добавить атрибут",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "должен быть сопоставлен с",
|
||||
"upload_contacts_modal_attributes_title": "Атрибуты",
|
||||
"upload_contacts_modal_csv_column_header": "Столбец CSV",
|
||||
"upload_contacts_modal_description": "Загрузите CSV, чтобы быстро импортировать контакты с атрибутами",
|
||||
"upload_contacts_modal_download_example_csv": "Скачать пример CSV",
|
||||
"upload_contacts_modal_duplicates_description": "Как поступить, если контакт уже существует в вашей базе?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Пока нет атрибутов!",
|
||||
"no_filters_yet": "Пока нет фильтров!",
|
||||
"no_segments_yet": "У вас пока нет сохранённых сегментов.",
|
||||
"operator_contains": "содержит",
|
||||
"operator_does_not_contain": "не содержит",
|
||||
"operator_ends_with": "оканчивается на",
|
||||
"operator_is_after": "после",
|
||||
"operator_is_before": "до",
|
||||
"operator_is_between": "находится между",
|
||||
"operator_is_newer_than": "новее чем",
|
||||
"operator_is_not_set": "не задано",
|
||||
"operator_is_older_than": "старше чем",
|
||||
"operator_is_same_day": "в тот же день",
|
||||
"operator_is_set": "задано",
|
||||
"operator_starts_with": "начинается с",
|
||||
"operator_title_contains": "Содержит",
|
||||
"operator_title_does_not_contain": "Не содержит",
|
||||
"operator_title_ends_with": "Оканчивается на",
|
||||
"operator_title_equals": "Равно",
|
||||
"operator_title_greater_equal": "Больше или равно",
|
||||
"operator_title_greater_than": "Больше чем",
|
||||
"operator_title_is_after": "После",
|
||||
"operator_title_is_before": "До",
|
||||
"operator_title_is_between": "Находится между",
|
||||
"operator_title_is_newer_than": "Новее чем",
|
||||
"operator_title_is_not_set": "Не задано",
|
||||
"operator_title_is_older_than": "Старше чем",
|
||||
"operator_title_is_same_day": "В тот же день",
|
||||
"operator_title_is_set": "Задано",
|
||||
"operator_title_less_equal": "Меньше или равно",
|
||||
"operator_title_less_than": "Меньше чем",
|
||||
"operator_title_not_equals": "Не равно",
|
||||
"operator_title_starts_with": "Начинается с",
|
||||
"operator_title_user_is_in": "Пользователь входит в",
|
||||
"operator_title_user_is_not_in": "Пользователь не входит в",
|
||||
"operator_user_is_in": "Пользователь входит в",
|
||||
"operator_user_is_not_in": "Пользователь не входит в",
|
||||
"person_and_attributes": "Пользователь и атрибуты",
|
||||
"phone": "Телефон",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Пожалуйста, удалите этот сегмент из указанных опросов, чтобы его удалить.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Таргетинг пользователей сейчас доступен только когда",
|
||||
"value_cannot_be_empty": "Значение не может быть пустым.",
|
||||
"value_must_be_a_number": "Значение должно быть числом.",
|
||||
"value_must_be_positive": "Значение должно быть положительным числом.",
|
||||
"view_filters": "Просмотреть фильтры",
|
||||
"where": "Где",
|
||||
"with_the_formbricks_sdk": "с помощью Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
|
||||
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
|
||||
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
|
||||
"advanced_styling_field_height": "Высота",
|
||||
"advanced_styling_field_height": "Минимальная высота",
|
||||
"advanced_styling_field_indicator_bg": "Фон индикатора",
|
||||
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
|
||||
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
|
||||
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
|
||||
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
|
||||
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
|
||||
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
|
||||
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
|
||||
"styling_updated_successfully": "Стили успешно обновлены",
|
||||
"suggest_colors": "Предложить цвета",
|
||||
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
|
||||
"theme": "Тема",
|
||||
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
"delete": "Ta bort",
|
||||
"description": "Beskrivning",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
|
||||
"months": "månader",
|
||||
"move_down": "Flytta ner",
|
||||
"move_up": "Flytta upp",
|
||||
"multiple_languages": "Flera språk",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Steg-för-steg-manual",
|
||||
"storage_not_configured": "Fillagring är inte konfigurerad, uppladdningar kommer sannolikt att misslyckas",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Skicka",
|
||||
"summary": "Sammanfattning",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "Webbplats- och appanslutning",
|
||||
"website_app_survey": "Webbplats- och appenkät",
|
||||
"website_survey": "Webbplatsenkät",
|
||||
"weeks": "veckor",
|
||||
"welcome_card": "Välkomstkort",
|
||||
"workspace_configuration": "Arbetsytans konfiguration",
|
||||
"workspace_created_successfully": "Arbetsytan har skapats",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "Arbetsyta hittades inte",
|
||||
"workspace_permission_not_found": "Arbetsytebehörighet hittades inte",
|
||||
"workspaces": "Arbetsytor",
|
||||
"years": "år",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "Attributet har uppdaterats",
|
||||
"attribute_value": "Värde",
|
||||
"attribute_value_placeholder": "Attributvärde",
|
||||
"attributes_msg_attribute_limit_exceeded": "Kunde inte skapa {count} nya attribut eftersom det skulle överskrida maxgränsen på {limit} attributklasser. Befintliga attribut uppdaterades utan problem.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attributet '{key}' har dataTyp: {dataType})",
|
||||
"attributes_msg_email_already_exists": "E-postadressen finns redan för den här miljön och uppdaterades inte.",
|
||||
"attributes_msg_email_or_userid_required": "Antingen e-post eller userId krävs. De befintliga värdena behölls.",
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut '{key}' med typen '{dataType}' har skapats",
|
||||
"attributes_msg_userid_already_exists": "UserId finns redan för den här miljön och uppdaterades inte.",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contact_not_found": "Ingen sådan kontakt hittades",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "Skapa nyckel",
|
||||
"create_new_attribute": "Skapa nytt attribut",
|
||||
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
||||
"custom_attributes": "Anpassade attribut",
|
||||
"data_type": "Datatyp",
|
||||
"data_type_cannot_be_changed": "Datatypen kan inte ändras efter skapande",
|
||||
"data_type_description": "Välj hur detta attribut ska lagras och filtreras",
|
||||
"date_value_required": "Datumvärde krävs. Använd ta bort-knappen om du inte vill ange ett datum.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Detta kommer att ta bort det valda attributet. All kontaktdata som är kopplad till detta attribut kommer att gå förlorad.} other {Detta kommer att ta bort de valda attributen. All kontaktdata som är kopplad till dessa attribut kommer att gå förlorad.}}",
|
||||
"delete_contact_confirmation": "Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad. Om denna kontakt har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.} other {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till dessa kontakter. All målgruppsinriktning och personalisering baserad på dessa kontakters data kommer att gå förlorad. Om dessa kontakter har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "Uppdatera etikett och beskrivning för detta attribut.",
|
||||
"edit_attribute_values": "Redigera attribut",
|
||||
"edit_attribute_values_description": "Ändra värdena för specifika attribut för denna kontakt.",
|
||||
"edit_attributes": "Redigera attribut",
|
||||
"edit_attributes_success": "Kontaktens attribut har uppdaterats",
|
||||
"generate_personal_link": "Generera personlig länk",
|
||||
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
|
||||
"invalid_csv_column_names": "Ogiltiga CSV-kolumnnamn: {columns}. Kolumnnamn som ska bli nya attribut får bara innehålla små bokstäver, siffror och understreck, och måste börja med en bokstav.",
|
||||
"invalid_date_format": "Ogiltigt datumformat. Ange ett giltigt datum.",
|
||||
"invalid_number_format": "Ogiltigt nummerformat. Ange ett giltigt nummer.",
|
||||
"no_published_link_surveys_available": "Inga publicerade länkenkäter tillgängliga. Vänligen publicera en länkenkät först.",
|
||||
"no_published_surveys": "Inga publicerade enkäter",
|
||||
"no_responses_found": "Inga svar hittades",
|
||||
"not_provided": "Ej angiven",
|
||||
"number_value_required": "Ett numeriskt värde krävs. Använd ta bort-knappen för att ta bort den här attributen.",
|
||||
"personal_link_generated": "Personlig länk genererad",
|
||||
"personal_link_generated_but_clipboard_failed": "Personlig länk genererad men kunde inte kopieras till urklipp: {url}",
|
||||
"personal_survey_link": "Personlig enkätlänk",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "Sök kontakt",
|
||||
"select_a_survey": "Välj en enkät",
|
||||
"select_attribute": "Välj attribut",
|
||||
"select_attribute_key": "Välj attributnyckel",
|
||||
"system_attributes": "Systemattribut",
|
||||
"unlock_contacts_description": "Hantera kontakter och skicka ut riktade enkäter",
|
||||
"unlock_contacts_title": "Lås upp kontakter med en högre plan",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attributet \"{key}\" är av typen \"{dataType}\" men CSV-filen innehåller ogiltiga värden: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Dubblettmappningar hittades för följande attribut: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Filstorleken överskrider maxgränsen på 800 KB",
|
||||
"upload_contacts_error_generic": "Ett fel uppstod vid uppladdning av kontakter. Försök igen senare.",
|
||||
"upload_contacts_error_invalid_file_type": "Ladda upp en CSV-fil",
|
||||
"upload_contacts_error_no_valid_contacts": "Den uppladdade CSV-filen innehåller inga giltiga kontakter, se exempel på CSV-fil för korrekt format.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks-attribut",
|
||||
"upload_contacts_modal_attributes_description": "Mappa kolumnerna i din CSV till attributen i Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "Nytt attribut",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Sök eller lägg till attribut",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "ska mappas till",
|
||||
"upload_contacts_modal_attributes_title": "Attribut",
|
||||
"upload_contacts_modal_csv_column_header": "CSV-kolumn",
|
||||
"upload_contacts_modal_description": "Ladda upp en CSV för att snabbt importera kontakter med attribut",
|
||||
"upload_contacts_modal_download_example_csv": "Ladda ner exempel-CSV",
|
||||
"upload_contacts_modal_duplicates_description": "Hur ska vi hantera om en kontakt redan finns i dina kontakter?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "Inga attribut ännu!",
|
||||
"no_filters_yet": "Det finns inga filter ännu!",
|
||||
"no_segments_yet": "Du har för närvarande inga sparade segment.",
|
||||
"operator_contains": "innehåller",
|
||||
"operator_does_not_contain": "innehåller inte",
|
||||
"operator_ends_with": "slutar med",
|
||||
"operator_is_after": "är efter",
|
||||
"operator_is_before": "är före",
|
||||
"operator_is_between": "är mellan",
|
||||
"operator_is_newer_than": "är nyare än",
|
||||
"operator_is_not_set": "är inte satt",
|
||||
"operator_is_older_than": "är äldre än",
|
||||
"operator_is_same_day": "är samma dag",
|
||||
"operator_is_set": "är satt",
|
||||
"operator_starts_with": "börjar med",
|
||||
"operator_title_contains": "Innehåller",
|
||||
"operator_title_does_not_contain": "Innehåller inte",
|
||||
"operator_title_ends_with": "Slutar med",
|
||||
"operator_title_equals": "Är lika med",
|
||||
"operator_title_greater_equal": "Större än eller lika med",
|
||||
"operator_title_greater_than": "Större än",
|
||||
"operator_title_is_after": "Är efter",
|
||||
"operator_title_is_before": "Är före",
|
||||
"operator_title_is_between": "Är mellan",
|
||||
"operator_title_is_newer_than": "Är nyare än",
|
||||
"operator_title_is_not_set": "Är inte satt",
|
||||
"operator_title_is_older_than": "Är äldre än",
|
||||
"operator_title_is_same_day": "Är samma dag",
|
||||
"operator_title_is_set": "Är satt",
|
||||
"operator_title_less_equal": "Mindre än eller lika med",
|
||||
"operator_title_less_than": "Mindre än",
|
||||
"operator_title_not_equals": "Är inte lika med",
|
||||
"operator_title_starts_with": "Börjar med",
|
||||
"operator_title_user_is_in": "Användaren är i",
|
||||
"operator_title_user_is_not_in": "Användaren är inte i",
|
||||
"operator_user_is_in": "Användaren är i",
|
||||
"operator_user_is_not_in": "Användaren är inte i",
|
||||
"person_and_attributes": "Person och attribut",
|
||||
"phone": "Telefon",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vänligen ta bort segmentet från dessa enkäter för att kunna ta bort det.",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Användarinriktning är för närvarande endast tillgänglig när",
|
||||
"value_cannot_be_empty": "Värdet kan inte vara tomt.",
|
||||
"value_must_be_a_number": "Värdet måste vara ett nummer.",
|
||||
"value_must_be_positive": "Värdet måste vara ett positivt nummer.",
|
||||
"view_filters": "Visa filter",
|
||||
"where": "Där",
|
||||
"with_the_formbricks_sdk": "med Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
|
||||
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
|
||||
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
|
||||
"advanced_styling_field_height": "Höjd",
|
||||
"advanced_styling_field_height": "Minsta höjd",
|
||||
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
|
||||
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
|
||||
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
|
||||
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
|
||||
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
|
||||
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
|
||||
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
|
||||
"styling_updated_successfully": "Stiluppdatering lyckades",
|
||||
"suggest_colors": "Föreslå färger",
|
||||
"suggested_colors_applied_please_save": "Föreslagna färger har skapats. Tryck på \"Spara\" för att spara ändringarna.",
|
||||
"theme": "Tema",
|
||||
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
"delete": "删除",
|
||||
"description": "描述",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"months": "月",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "状态",
|
||||
"step_by_step_manual": "分步 手册",
|
||||
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
|
||||
"string": "文本",
|
||||
"styling": "样式",
|
||||
"submit": "提交",
|
||||
"summary": "概要",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "网站 & 应用程序 连接",
|
||||
"website_app_survey": "网站 & 应用 调查",
|
||||
"website_survey": "网站 调查",
|
||||
"weeks": "周",
|
||||
"welcome_card": "欢迎 卡片",
|
||||
"workspace_configuration": "工作区配置",
|
||||
"workspace_created_successfully": "工作区创建成功",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "未找到工作区",
|
||||
"workspace_permission_not_found": "未找到工作区权限",
|
||||
"workspaces": "工作区",
|
||||
"years": "年",
|
||||
"you": "你 ",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "属性更新成功",
|
||||
"attribute_value": "值",
|
||||
"attribute_value_placeholder": "属性值",
|
||||
"attributes_msg_attribute_limit_exceeded": "无法创建 {count} 个新属性,因为这将超过最多 {limit} 个属性类别的限制。已有属性已成功更新。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(属性“{key}”的数据类型为:{dataType})",
|
||||
"attributes_msg_email_already_exists": "该邮箱已存在于当前环境,未进行更新。",
|
||||
"attributes_msg_email_or_userid_required": "必须填写邮箱或 userId。已保留原有值。",
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该 userId 已存在于当前环境,未进行更新。",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contact_not_found": "未找到此 联系人",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "创建键",
|
||||
"create_new_attribute": "创建新属性",
|
||||
"create_new_attribute_description": "为细分目的创建新属性。",
|
||||
"custom_attributes": "自定义属性",
|
||||
"data_type": "数据类型",
|
||||
"data_type_cannot_be_changed": "数据类型创建后无法更改",
|
||||
"data_type_description": "选择此属性的存储和筛选方式",
|
||||
"date_value_required": "需要日期值。如果你不想设置日期,请使用删除按钮移除此属性。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {这将删除所选属性。与该属性相关的任何联系人数据都将丢失。} other {这将删除所选属性。与这些属性相关的任何联系人数据都将丢失。}}",
|
||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "更新此属性的标签和描述。",
|
||||
"edit_attribute_values": "编辑属性",
|
||||
"edit_attribute_values_description": "更改此联系人的特定属性值。",
|
||||
"edit_attributes": "编辑属性",
|
||||
"edit_attributes_success": "联系人属性更新成功",
|
||||
"generate_personal_link": "生成个人链接",
|
||||
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
|
||||
"invalid_csv_column_names": "无效的 CSV 列名:{columns}。作为新属性的列名只能包含小写字母、数字和下划线,并且必须以字母开头。",
|
||||
"invalid_date_format": "日期格式无效。请使用有效日期。",
|
||||
"invalid_number_format": "数字格式无效。请输入有效的数字。",
|
||||
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
||||
"no_published_surveys": "没有已发布的调查",
|
||||
"no_responses_found": "未找到 响应",
|
||||
"not_provided": "未提供",
|
||||
"number_value_required": "需要填写数字值。要移除此属性,请使用删除按钮。",
|
||||
"personal_link_generated": "个人链接生成成功",
|
||||
"personal_link_generated_but_clipboard_failed": "个性化链接已生成,但复制到剪贴板失败:{url}",
|
||||
"personal_survey_link": "个人调查链接",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "搜索 联系人",
|
||||
"select_a_survey": "选择一个调查",
|
||||
"select_attribute": "选择 属性",
|
||||
"select_attribute_key": "选择属性键",
|
||||
"system_attributes": "系统属性",
|
||||
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
|
||||
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
|
||||
"upload_contacts_error_attribute_type_mismatch": "属性“{key}”的数据类型为“{dataType}”,但 CSV 文件中包含无效值:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "以下属性存在重复映射:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "文件大小超过最大限制 800KB",
|
||||
"upload_contacts_error_generic": "上传联系人时发生错误,请稍后再试。",
|
||||
"upload_contacts_error_invalid_file_type": "请上传 CSV 文件",
|
||||
"upload_contacts_error_no_valid_contacts": "上传的 CSV 文件中不包含任何有效联系人,请参考示例 CSV 文件获取正确格式。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks 属性",
|
||||
"upload_contacts_modal_attributes_description": "将您 CSV 中的列映射到 Formbricks 中的属性。",
|
||||
"upload_contacts_modal_attributes_new": "新 属性",
|
||||
"upload_contacts_modal_attributes_search_or_add": "搜索或添加属性",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "应该映射到",
|
||||
"upload_contacts_modal_attributes_title": "属性",
|
||||
"upload_contacts_modal_csv_column_header": "CSV 列",
|
||||
"upload_contacts_modal_description": "上传 CSV,快速 导入 具有 属性 的 联系人",
|
||||
"upload_contacts_modal_download_example_csv": "下载 示例 CSV",
|
||||
"upload_contacts_modal_duplicates_description": "如果联系人已经存在,应该如何处理?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "暂无属性!",
|
||||
"no_filters_yet": "还 没有 筛选器!",
|
||||
"no_segments_yet": "您 目前 尚无 保存 的 段。",
|
||||
"operator_contains": "包含",
|
||||
"operator_does_not_contain": "不包含",
|
||||
"operator_ends_with": "以...结束",
|
||||
"operator_is_after": "在...之后",
|
||||
"operator_is_before": "在...之前",
|
||||
"operator_is_between": "介于...之间",
|
||||
"operator_is_newer_than": "比...更新",
|
||||
"operator_is_not_set": "未设置",
|
||||
"operator_is_older_than": "比...更早",
|
||||
"operator_is_same_day": "同一天",
|
||||
"operator_is_set": "已设置",
|
||||
"operator_starts_with": "以...开始",
|
||||
"operator_title_contains": "包含",
|
||||
"operator_title_does_not_contain": "不包含",
|
||||
"operator_title_ends_with": "以...结束",
|
||||
"operator_title_equals": "等于",
|
||||
"operator_title_greater_equal": "大于或等于",
|
||||
"operator_title_greater_than": "大于",
|
||||
"operator_title_is_after": "在...之后",
|
||||
"operator_title_is_before": "在...之前",
|
||||
"operator_title_is_between": "介于...之间",
|
||||
"operator_title_is_newer_than": "比...更新",
|
||||
"operator_title_is_not_set": "未设置",
|
||||
"operator_title_is_older_than": "比...更早",
|
||||
"operator_title_is_same_day": "同一天",
|
||||
"operator_title_is_set": "已设置",
|
||||
"operator_title_less_equal": "小于或等于",
|
||||
"operator_title_less_than": "小于",
|
||||
"operator_title_not_equals": "不等于",
|
||||
"operator_title_starts_with": "以...开始",
|
||||
"operator_title_user_is_in": "用户属于",
|
||||
"operator_title_user_is_not_in": "用户不属于",
|
||||
"operator_user_is_in": "用户属于",
|
||||
"operator_user_is_not_in": "用户不属于",
|
||||
"person_and_attributes": "人员 及 属性",
|
||||
"phone": "电话",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "请 从 这些 调查 中 移除 该 部分 以 进行 删除。",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "目标用户 功能 当前 仅 限于 当",
|
||||
"value_cannot_be_empty": "值 不能为空。",
|
||||
"value_must_be_a_number": "值 必须 是 一个 数字。",
|
||||
"value_must_be_positive": "值必须是正数。",
|
||||
"view_filters": "查看 筛选条件",
|
||||
"where": "位置",
|
||||
"with_the_formbricks_sdk": "与 Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
|
||||
"advanced_styling_field_headline_weight": "标题字体粗细",
|
||||
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
|
||||
"advanced_styling_field_height": "高度",
|
||||
"advanced_styling_field_height": "最小高度",
|
||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
|
||||
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
|
||||
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
|
||||
"advanced_styling_field_input_height_description": "控制输入框高度。",
|
||||
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
|
||||
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
|
||||
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
|
||||
"styling_updated_successfully": "样式更新成功",
|
||||
"suggest_colors": "推荐颜色",
|
||||
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
|
||||
"theme": "主题",
|
||||
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
"delete": "刪除",
|
||||
"description": "描述",
|
||||
@@ -275,6 +276,7 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"months": "月",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -393,6 +395,7 @@
|
||||
"status": "狀態",
|
||||
"step_by_step_manual": "逐步手冊",
|
||||
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
|
||||
"string": "文字",
|
||||
"styling": "樣式設定",
|
||||
"submit": "提交",
|
||||
"summary": "摘要",
|
||||
@@ -448,6 +451,7 @@
|
||||
"website_and_app_connection": "網站與應用程式連線",
|
||||
"website_app_survey": "網站與應用程式問卷",
|
||||
"website_survey": "網站問卷",
|
||||
"weeks": "週",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"workspace_configuration": "工作區設定",
|
||||
"workspace_created_successfully": "工作區已成功建立",
|
||||
@@ -458,6 +462,7 @@
|
||||
"workspace_not_found": "找不到工作區",
|
||||
"workspace_permission_not_found": "找不到工作區權限",
|
||||
"workspaces": "工作區",
|
||||
"years": "年",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
|
||||
@@ -629,6 +634,12 @@
|
||||
"attribute_updated_successfully": "屬性更新成功",
|
||||
"attribute_value": "值",
|
||||
"attribute_value_placeholder": "屬性值",
|
||||
"attributes_msg_attribute_limit_exceeded": "無法建立 {count} 個新屬性,因為這樣會超過 {limit} 個屬性類別的上限。現有屬性已成功更新。",
|
||||
"attributes_msg_attribute_type_validation_error": "{error}(屬性「{key}」的資料型別為:{dataType})",
|
||||
"attributes_msg_email_already_exists": "此環境已存在該 email,未進行更新。",
|
||||
"attributes_msg_email_or_userid_required": "必須提供 email 或 userId。已保留現有值。",
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該 userId,未進行更新。",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
@@ -637,6 +648,11 @@
|
||||
"create_key": "建立金鑰",
|
||||
"create_new_attribute": "建立新屬性",
|
||||
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
||||
"custom_attributes": "自訂屬性",
|
||||
"data_type": "資料型態",
|
||||
"data_type_cannot_be_changed": "建立後無法變更資料型態",
|
||||
"data_type_description": "選擇此屬性要如何儲存與篩選",
|
||||
"date_value_required": "必須填寫日期值。如果你不想設定日期,請用刪除按鈕移除此屬性。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {這將刪除所選屬性。與此屬性相關的聯絡人資料將會遺失。} other {這將刪除所選屬性。與這些屬性相關的聯絡人資料將會遺失。}}",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||
@@ -644,13 +660,18 @@
|
||||
"edit_attribute_description": "更新此屬性的標籤與描述。",
|
||||
"edit_attribute_values": "編輯屬性",
|
||||
"edit_attribute_values_description": "變更此聯絡人特定屬性的值。",
|
||||
"edit_attributes": "編輯屬性",
|
||||
"edit_attributes_success": "聯絡人屬性已成功更新",
|
||||
"generate_personal_link": "產生個人連結",
|
||||
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
|
||||
"invalid_csv_column_names": "無效的 CSV 欄位名稱:{columns}。作為新屬性的欄位名稱只能包含小寫字母、數字和底線,且必須以字母開頭。",
|
||||
"invalid_date_format": "日期格式無效。請使用有效的日期。",
|
||||
"invalid_number_format": "數字格式無效。請輸入有效的數字。",
|
||||
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
||||
"no_published_surveys": "沒有已發佈的問卷",
|
||||
"no_responses_found": "找不到回應",
|
||||
"not_provided": "未提供",
|
||||
"number_value_required": "必須填寫數字值。如要移除此屬性,請使用刪除按鈕。",
|
||||
"personal_link_generated": "個人連結已成功產生",
|
||||
"personal_link_generated_but_clipboard_failed": "已生成個人連結,但無法複製到剪貼簿:{url}",
|
||||
"personal_survey_link": "個人調查連結",
|
||||
@@ -659,13 +680,22 @@
|
||||
"search_contact": "搜尋聯絡人",
|
||||
"select_a_survey": "選擇問卷",
|
||||
"select_attribute": "選取屬性",
|
||||
"select_attribute_key": "選取屬性鍵值",
|
||||
"system_attributes": "系統屬性",
|
||||
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
|
||||
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
|
||||
"upload_contacts_error_attribute_type_mismatch": "屬性「{key}」的類型為「{dataType}」,但 CSV 檔案中包含無效的值:{values}",
|
||||
"upload_contacts_error_duplicate_mappings": "以下屬性有重複對應:{attributes}",
|
||||
"upload_contacts_error_file_too_large": "檔案大小超過 800KB 的上限",
|
||||
"upload_contacts_error_generic": "上傳聯絡人時發生錯誤,請稍後再試。",
|
||||
"upload_contacts_error_invalid_file_type": "請上傳 CSV 檔案",
|
||||
"upload_contacts_error_no_valid_contacts": "上傳的 CSV 檔案中沒有任何有效的聯絡人,請參考範例 CSV 檔案以取得正確格式。",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks 屬性",
|
||||
"upload_contacts_modal_attributes_description": "將 CSV 中的欄位對應到 Formbricks 中的屬性。",
|
||||
"upload_contacts_modal_attributes_new": "新增屬性",
|
||||
"upload_contacts_modal_attributes_search_or_add": "搜尋或新增屬性",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "應對應到",
|
||||
"upload_contacts_modal_attributes_title": "屬性",
|
||||
"upload_contacts_modal_csv_column_header": "CSV 欄位",
|
||||
"upload_contacts_modal_description": "上傳 CSV 以快速匯入具有屬性的聯絡人",
|
||||
"upload_contacts_modal_download_example_csv": "下載範例 CSV",
|
||||
"upload_contacts_modal_duplicates_description": "如果聯絡人已存在於您的聯絡人中,我們應該如何處理?",
|
||||
@@ -846,6 +876,40 @@
|
||||
"no_attributes_yet": "尚無屬性!",
|
||||
"no_filters_yet": "尚無篩選器!",
|
||||
"no_segments_yet": "您目前沒有已儲存的區隔。",
|
||||
"operator_contains": "包含",
|
||||
"operator_does_not_contain": "不包含",
|
||||
"operator_ends_with": "結尾為",
|
||||
"operator_is_after": "在之後",
|
||||
"operator_is_before": "在之前",
|
||||
"operator_is_between": "介於",
|
||||
"operator_is_newer_than": "較新於",
|
||||
"operator_is_not_set": "未設定",
|
||||
"operator_is_older_than": "較舊於",
|
||||
"operator_is_same_day": "同一天",
|
||||
"operator_is_set": "已設定",
|
||||
"operator_starts_with": "開頭為",
|
||||
"operator_title_contains": "包含",
|
||||
"operator_title_does_not_contain": "不包含",
|
||||
"operator_title_ends_with": "結尾為",
|
||||
"operator_title_equals": "等於",
|
||||
"operator_title_greater_equal": "大於或等於",
|
||||
"operator_title_greater_than": "大於",
|
||||
"operator_title_is_after": "在之後",
|
||||
"operator_title_is_before": "在之前",
|
||||
"operator_title_is_between": "介於",
|
||||
"operator_title_is_newer_than": "較新於",
|
||||
"operator_title_is_not_set": "未設定",
|
||||
"operator_title_is_older_than": "較舊於",
|
||||
"operator_title_is_same_day": "同一天",
|
||||
"operator_title_is_set": "已設定",
|
||||
"operator_title_less_equal": "小於或等於",
|
||||
"operator_title_less_than": "小於",
|
||||
"operator_title_not_equals": "不等於",
|
||||
"operator_title_starts_with": "開頭為",
|
||||
"operator_title_user_is_in": "使用者屬於",
|
||||
"operator_title_user_is_not_in": "使用者不屬於",
|
||||
"operator_user_is_in": "使用者屬於",
|
||||
"operator_user_is_not_in": "使用者不屬於",
|
||||
"person_and_attributes": "人員與屬性",
|
||||
"phone": "電話",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "請從這些問卷中移除區隔,以便將其刪除。",
|
||||
@@ -870,6 +934,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "使用者目標設定目前僅在以下情況下可用:",
|
||||
"value_cannot_be_empty": "值不能為空。",
|
||||
"value_must_be_a_number": "值必須是數字。",
|
||||
"value_must_be_positive": "值必須是正數。",
|
||||
"view_filters": "檢視篩選器",
|
||||
"where": "何處",
|
||||
"with_the_formbricks_sdk": "使用 Formbricks SDK"
|
||||
@@ -2088,12 +2153,12 @@
|
||||
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
|
||||
"advanced_styling_field_headline_weight": "標題字體粗細",
|
||||
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
|
||||
"advanced_styling_field_height": "高度",
|
||||
"advanced_styling_field_height": "最小高度",
|
||||
"advanced_styling_field_indicator_bg": "指示器背景",
|
||||
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
|
||||
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
|
||||
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
|
||||
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
|
||||
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
|
||||
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
|
||||
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
|
||||
@@ -2156,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
|
||||
"styling_updated_successfully": "樣式已成功更新",
|
||||
"suggest_colors": "建議顏色",
|
||||
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
|
||||
"theme": "主題",
|
||||
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
@{part}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
|
||||
import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils";
|
||||
import {
|
||||
TContactAttributeKeyInput,
|
||||
@@ -37,7 +38,7 @@ export const getContactAttributeKeys = reactCache(
|
||||
export const createContactAttributeKey = async (
|
||||
contactAttributeKey: TContactAttributeKeyInput
|
||||
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
const { environmentId, name, description, key } = contactAttributeKey;
|
||||
const { environmentId, name, description, key, dataType } = contactAttributeKey;
|
||||
|
||||
try {
|
||||
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
|
||||
@@ -46,9 +47,10 @@ export const createContactAttributeKey = async (
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
name,
|
||||
name: name ?? formatSnakeCaseToTitleCase(key),
|
||||
description,
|
||||
key,
|
||||
...(dataType && { dataType }),
|
||||
};
|
||||
|
||||
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
|
||||
@@ -30,6 +30,9 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
description: true,
|
||||
environmentId: true,
|
||||
})
|
||||
.extend({
|
||||
dataType: ZContactAttributeKey.shape.dataType.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Enforce safe identifier format for key
|
||||
if (!isSafeIdentifier(data.key)) {
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all API configurations", () => {
|
||||
const apiConfigs = Object.keys(rateLimitConfigs.api);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "client", "syncUserIdentification"]);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
|
||||
});
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
@@ -137,7 +137,6 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
|
||||
{ config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
|
||||
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
|
||||
@@ -160,31 +159,6 @@ describe("rateLimitConfigs", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should properly configure syncUserIdentification rate limit", async () => {
|
||||
const config = rateLimitConfigs.api.syncUserIdentification;
|
||||
|
||||
// Verify configuration values
|
||||
expect(config.interval).toBe(60); // 1 minute
|
||||
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
|
||||
expect(config.namespace).toBe("api:sync-user-identification");
|
||||
|
||||
// Test with allowed request
|
||||
mockEval.mockResolvedValue([1, 1]); // 1 request used, allowed (1 = true)
|
||||
const allowedResult = await checkRateLimit(config, "env-user-123");
|
||||
expect(allowedResult.ok).toBe(true);
|
||||
if (allowedResult.ok) {
|
||||
expect(allowedResult.data.allowed).toBe(true);
|
||||
}
|
||||
|
||||
// Test when limit is exceeded
|
||||
mockEval.mockResolvedValue([6, 0]); // 6 requests used (exceeds limit of 5), not allowed (0 = false)
|
||||
const exceededResult = await checkRateLimit(config, "env-user-123");
|
||||
expect(exceededResult.ok).toBe(true);
|
||||
if (exceededResult.ok) {
|
||||
expect(exceededResult.data.allowed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("should properly configure storage upload rate limit", async () => {
|
||||
const config = rateLimitConfigs.storage.upload;
|
||||
|
||||
|
||||
@@ -12,11 +12,6 @@ export const rateLimitConfigs = {
|
||||
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
|
||||
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
|
||||
syncUserIdentification: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
namespace: "api:sync-user-identification",
|
||||
}, // 5 per minute per environment-user pair
|
||||
},
|
||||
|
||||
// Server actions - varies by action type
|
||||
@@ -35,4 +30,4 @@ export const rateLimitConfigs = {
|
||||
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
|
||||
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
29
apps/web/modules/ee/analytics/api/lib/cube-client.test.ts
Normal file
29
apps/web/modules/ee/analytics/api/lib/cube-client.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { executeQuery } from "./cube-client";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
});
|
||||
25
apps/web/modules/ee/analytics/api/lib/cube-client.ts
Normal file
25
apps/web/modules/ee/analytics/api/lib/cube-client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getResponsesByContactId } from "@/lib/response/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getContactAttributeDataTypeIcon } from "@/modules/ee/contacts/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
|
||||
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
|
||||
const t = await getTranslate();
|
||||
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
|
||||
const [contact, attributesWithKeyInfo] = await Promise.all([
|
||||
getContact(contactId),
|
||||
getContactAttributesWithKeyInfo(contactId),
|
||||
]);
|
||||
|
||||
if (!contact) {
|
||||
throw new Error(t("environments.contacts.contact_not_found"));
|
||||
@@ -15,54 +20,65 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
|
||||
const responses = await getResponsesByContactId(contactId);
|
||||
const numberOfResponses = responses?.length || 0;
|
||||
|
||||
const systemAttributes = attributesWithKeyInfo
|
||||
.filter((attr) => attr.type === "default")
|
||||
.sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key));
|
||||
|
||||
const customAttributes = attributesWithKeyInfo
|
||||
.filter((attr) => attr.type === "custom")
|
||||
.sort((a, b) => (a.name || a.key).localeCompare(b.name || b.key));
|
||||
|
||||
const renderAttributeValue = (attr: (typeof attributesWithKeyInfo)[number]) => {
|
||||
if (!attr.value) {
|
||||
return <span className="text-slate-300">{t("environments.contacts.not_provided")}</span>;
|
||||
}
|
||||
|
||||
// Special handling for userId to show IdBadge
|
||||
if (attr.key === "userId") {
|
||||
return <IdBadge id={attr.value} />;
|
||||
}
|
||||
|
||||
return formatAttributeValue(attr.value, attr.dataType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
|
||||
<h2 className="text-lg font-bold text-slate-700">{t("environments.contacts.system_attributes")}</h2>
|
||||
|
||||
{systemAttributes.map((attr) => (
|
||||
<div key={attr.key}>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span className="text-slate-400">{getContactAttributeDataTypeIcon(attr.dataType)}</span>
|
||||
<span>{attr.name || attr.key}</span>
|
||||
</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{renderAttributeValue(attr)}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">email</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.email ? (
|
||||
<span>{attributes.email}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">language</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.language ? (
|
||||
<span>{attributes.language}</span>
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">userId</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
|
||||
{attributes.userId ? (
|
||||
<IdBadge id={attributes.userId} />
|
||||
) : (
|
||||
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-slate-500">contactId</dt>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span className="text-slate-400">{getContactAttributeDataTypeIcon("string")}</span>
|
||||
<span>contactId</span>
|
||||
</dt>
|
||||
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
|
||||
</div>
|
||||
|
||||
{Object.entries(attributes)
|
||||
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
|
||||
.map(([key, attributeData]) => {
|
||||
return (
|
||||
<div key={key}>
|
||||
<dt className="text-sm font-medium text-slate-500">{key}</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
|
||||
{customAttributes.length > 0 && (
|
||||
<>
|
||||
<hr />
|
||||
<h2 className="text-lg font-bold text-slate-700">{t("environments.contacts.custom_attributes")}</h2>
|
||||
{customAttributes.map((attr) => (
|
||||
<div key={attr.key}>
|
||||
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||
<span className="text-slate-400">{getContactAttributeDataTypeIcon(attr.dataType)}</span>
|
||||
<span>{attr.name || attr.key}</span>
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-900">{renderAttributeValue(attr)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
|
||||
@@ -15,14 +14,21 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
|
||||
|
||||
interface TContactAttributeWithKeyInfo {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface ContactControlBarProps {
|
||||
environmentId: string;
|
||||
contactId: string;
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
publishedLinkSurveys: PublishedLinkSurvey[];
|
||||
currentAttributes: TContactAttributes;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
allAttributeKeys: TContactAttributeKey[];
|
||||
currentAttributes: TContactAttributeWithKeyInfo[];
|
||||
}
|
||||
|
||||
export const ContactControlBar = ({
|
||||
@@ -31,8 +37,8 @@ export const ContactControlBar = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
publishedLinkSurveys,
|
||||
allAttributeKeys,
|
||||
currentAttributes,
|
||||
attributeKeys,
|
||||
}: ContactControlBarProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@@ -63,7 +69,7 @@ export const ContactControlBar = ({
|
||||
const iconActions = [
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("environments.contacts.edit_attribute_values"),
|
||||
tooltip: t("environments.contacts.edit_attributes"),
|
||||
onClick: () => {
|
||||
setIsEditAttributesModalOpen(true);
|
||||
},
|
||||
@@ -115,7 +121,7 @@ export const ContactControlBar = ({
|
||||
setOpen={setIsEditAttributesModalOpen}
|
||||
contactId={contactId}
|
||||
currentAttributes={currentAttributes}
|
||||
attributeKeys={attributeKeys}
|
||||
attributeKeys={allAttributeKeys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,9 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
|
||||
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import { getContact } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
|
||||
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
@@ -22,12 +21,12 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
|
||||
const [environmentTags, contact, publishedLinkSurveys, attributesWithKeyInfo, allAttributeKeys] =
|
||||
await Promise.all([
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getContact(params.contactId),
|
||||
getContactAttributes(params.contactId),
|
||||
getPublishedLinkSurveys(params.environmentId),
|
||||
getContactAttributesWithKeyInfo(params.contactId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
@@ -37,6 +36,13 @@ export const SingleContactPage = async (props: {
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.billing.plan);
|
||||
|
||||
// Derive contact identifier from metadata array
|
||||
const getAttributeValue = (key: string): string | undefined => {
|
||||
return attributesWithKeyInfo.find((attr) => attr.key === key)?.value;
|
||||
};
|
||||
|
||||
const contactIdentifier = getAttributeValue("email") || getAttributeValue("userId") || "";
|
||||
|
||||
const getContactControlBar = () => {
|
||||
return (
|
||||
<ContactControlBar
|
||||
@@ -45,8 +51,8 @@ export const SingleContactPage = async (props: {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
publishedLinkSurveys={publishedLinkSurveys}
|
||||
currentAttributes={contactAttributes}
|
||||
attributeKeys={contactAttributeKeys}
|
||||
currentAttributes={attributesWithKeyInfo}
|
||||
allAttributeKeys={allAttributeKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -54,8 +60,8 @@ export const SingleContactPage = async (props: {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`/environments/${params.environmentId}/contacts`} />
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
|
||||
<section className="pt-6 pb-24">
|
||||
<PageHeader pageTitle={contactIdentifier} cta={getContactControlBar()} />
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
<ResponseSection
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -124,9 +124,13 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
|
||||
parsedInput.duplicateContactsAction,
|
||||
parsedInput.attributeMap
|
||||
);
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
contacts: result,
|
||||
};
|
||||
|
||||
if ("contacts" in result) {
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
contacts: result.contacts,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
)
|
||||
@@ -134,7 +138,7 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
|
||||
|
||||
const ZUpdateContactAttributesAction = z.object({
|
||||
contactId: ZId,
|
||||
attributes: ZContactAttributes,
|
||||
attributes: ZContactAttributesInput,
|
||||
});
|
||||
|
||||
export type TUpdateContactAttributesAction = z.infer<typeof ZUpdateContactAttributesAction>;
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactByUserIdWithAttributes } from "./contact";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "testEnvironmentId";
|
||||
const mockUserId = "testUserId";
|
||||
const mockContactId = "testContactId";
|
||||
|
||||
describe("getContactByUserIdWithAttributes", () => {
|
||||
test("should return contact with filtered attributes when found", async () => {
|
||||
const mockUpdatedAttributes = { email: "new@example.com", plan: "premium" };
|
||||
const mockDbContact = {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "new@example.com" },
|
||||
{ attributeKey: { key: "plan" }, value: "premium" },
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockDbContact as any);
|
||||
|
||||
const result = await getContactByUserIdWithAttributes(
|
||||
mockEnvironmentId,
|
||||
mockUserId,
|
||||
mockUpdatedAttributes
|
||||
);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: Object.keys(mockUpdatedAttributes),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockDbContact);
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
const mockUpdatedAttributes = { email: "new@example.com" };
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getContactByUserIdWithAttributes(
|
||||
mockEnvironmentId,
|
||||
mockUserId,
|
||||
mockUpdatedAttributes
|
||||
);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: Object.keys(mockUpdatedAttributes),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle empty updatedAttributes", async () => {
|
||||
const mockUpdatedAttributes = {};
|
||||
const mockDbContact = {
|
||||
id: mockContactId,
|
||||
attributes: [], // No attributes should be fetched if updatedAttributes is empty
|
||||
};
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockDbContact as any);
|
||||
|
||||
const result = await getContactByUserIdWithAttributes(
|
||||
mockEnvironmentId,
|
||||
mockUserId,
|
||||
mockUpdatedAttributes
|
||||
);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: [], // Object.keys({}) results in an empty array
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockDbContact);
|
||||
});
|
||||
|
||||
test("should return contact with only requested attributes even if DB stores more", async () => {
|
||||
const mockUpdatedAttributes = { email: "new@example.com" }; // only request email
|
||||
// The prisma call will filter attributes based on `Object.keys(mockUpdatedAttributes)`
|
||||
const mockPrismaResponse = {
|
||||
id: mockContactId,
|
||||
attributes: [{ attributeKey: { key: "email" }, value: "new@example.com" }],
|
||||
};
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockPrismaResponse as any);
|
||||
|
||||
const result = await getContactByUserIdWithAttributes(
|
||||
mockEnvironmentId,
|
||||
mockUserId,
|
||||
mockUpdatedAttributes
|
||||
);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
attributes: {
|
||||
some: { attributeKey: { key: "userId", environmentId: mockEnvironmentId }, value: mockUserId },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: ["email"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockPrismaResponse);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserIdWithAttributes = reactCache(
|
||||
async (environmentId: string, userId: string, updatedAttributes: Record<string, string>) => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
environmentId,
|
||||
attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: {
|
||||
in: Object.keys(updatedAttributes),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
);
|
||||
@@ -1,137 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getContactByUserIdWithAttributes } from "./lib/contact";
|
||||
|
||||
const validateParams = (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): { isValid: true } | { isValid: false; error: Response } => {
|
||||
if (!environmentId) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: responses.badRequestResponse("environmentId is required", { environmentId }, true),
|
||||
};
|
||||
}
|
||||
if (!userId) {
|
||||
return { isValid: false, error: responses.badRequestResponse("userId is required", { userId }, true) };
|
||||
}
|
||||
return { isValid: true };
|
||||
};
|
||||
|
||||
const checkIfAttributesNeedUpdate = (contact: any, updatedAttributes: Record<string, string>) => {
|
||||
const oldAttributes = new Map(contact.attributes.map((attr: any) => [attr.attributeKey.key, attr.value]));
|
||||
|
||||
for (const [key, value] of Object.entries(updatedAttributes)) {
|
||||
if (value !== oldAttributes.get(key)) {
|
||||
return false; // needs update
|
||||
}
|
||||
}
|
||||
return true; // up to date
|
||||
};
|
||||
|
||||
export const OPTIONS = async () => {
|
||||
// cors headers
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
try {
|
||||
const params = await props.params;
|
||||
const { environmentId, userId } = params;
|
||||
|
||||
// Validate required parameters
|
||||
const paramValidation = validateParams(environmentId, userId);
|
||||
if (!paramValidation.isValid) {
|
||||
return { response: paramValidation.error };
|
||||
}
|
||||
|
||||
// Parse and validate input
|
||||
const jsonInput = await req.json();
|
||||
const parsedInput = ZJsContactsUpdateAttributeInput.safeParse(jsonInput);
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(parsedInput.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check enterprise license
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Process attributes (ignore userId and id)
|
||||
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = parsedInput.data.attributes;
|
||||
|
||||
const contact = await getContactByUserIdWithAttributes(environmentId, userId, updatedAttributes);
|
||||
if (!contact) {
|
||||
return { response: responses.notFoundResponse("contact", userId, true) };
|
||||
}
|
||||
|
||||
// Check if update is needed
|
||||
const isUpToDate = checkIfAttributesNeedUpdate(contact, updatedAttributes);
|
||||
if (isUpToDate) {
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
{ changed: false, message: "No updates were necessary; the person is already up to date." },
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Perform update
|
||||
const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(
|
||||
{
|
||||
changed: true,
|
||||
message: "The person was successfully updated.",
|
||||
...(messages && messages.length > 0 ? { messages } : {}),
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error({ err, url: req.url }, "Error updating attributes");
|
||||
if (err.statusCode === 403) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true }),
|
||||
};
|
||||
}
|
||||
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse(err.resourceType, err.resourceId, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong", true),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ValidationError } from "@formbricks/types/errors";
|
||||
import { getContactAttributes } from "./attributes";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contactAttribute: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockContactId = "xn8b8ol97q2pcp8dnlpsfs1m";
|
||||
|
||||
describe("getContactAttributes", () => {
|
||||
test("should return transformed attributes when found", async () => {
|
||||
const mockContactAttributes = [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "name" }, value: "Test User" },
|
||||
];
|
||||
const expectedTransformedAttributes = {
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue(mockContactAttributes);
|
||||
|
||||
const result = await getContactAttributes(mockContactId);
|
||||
|
||||
expect(result).toEqual(expectedTransformedAttributes);
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId: mockContactId,
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return an empty object when no attributes are found", async () => {
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getContactAttributes(mockContactId);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
contactId: mockContactId,
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw a ValidationError when contactId is invalid", async () => {
|
||||
const invalidContactId = "hello-world";
|
||||
|
||||
await expect(getContactAttributes(invalidContactId)).rejects.toThrowError(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getContactAttributes = reactCache(async (contactId: string): Promise<Record<string, string>> => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
const contactAttributes = await prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
select: { attributeKey: { select: { key: true } }, value: true },
|
||||
});
|
||||
|
||||
const transformedContactAttributes: Record<string, string> = contactAttributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return transformedContactAttributes;
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "clxmg5n79000008l9df7b8nh8";
|
||||
const mockUserId = "dpqs2axc6v3b5cjcgtnqhwov";
|
||||
const mockContactId = "clxmg5n79000108l9df7b8xyz";
|
||||
|
||||
const mockReturnedContact = {
|
||||
id: mockContactId,
|
||||
environmentId: mockEnvironmentId,
|
||||
createdAt: new Date("2024-01-01T10:00:00.000Z"),
|
||||
updatedAt: new Date("2024-01-01T11:00:00.000Z"),
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contact if found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockReturnedContact as any);
|
||||
|
||||
const result = await getContactByUserId(mockEnvironmentId, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockReturnedContact);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getContactByUserId(mockEnvironmentId, mockUserId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should call prisma.contact.findFirst with correct parameters", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockReturnedContact as any);
|
||||
await getContactByUserId(mockEnvironmentId, mockUserId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserId = reactCache(async (environmentId: string, userId: string) => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getPersonSegmentIds } from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments";
|
||||
import { getContactByUserId } from "./contact";
|
||||
import { getPersonState } from "./person-state";
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact", () => ({
|
||||
getContactByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes",
|
||||
() => ({
|
||||
getContactAttributes: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments", () => ({
|
||||
getPersonSegmentIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "jubz514cwdmjvnbadsfd7ez3";
|
||||
const mockUserId = "huli1kfpw1r6vn00vjxetdob";
|
||||
const mockContactId = "e71zwzi6zgrdzutbb0q8spui";
|
||||
const mockProjectId = "d6o07l7ieizdioafgelrioao";
|
||||
const mockOrganizationId = "xa4oltlfkmqq3r4e3m3ocss1";
|
||||
const mockDevice = "desktop";
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: mockProjectId,
|
||||
appSetupCompleted: false,
|
||||
};
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: mockOrganizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Organization",
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: { projects: 1, monthly: { responses: 100, miu: 100 } },
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockResolvedContactFromGetContactByUserId = {
|
||||
id: mockContactId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
const mockResolvedContactFromPrismaCreate = {
|
||||
id: mockContactId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
describe("getPersonState", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if environment is not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
await expect(
|
||||
getPersonState({ environmentId: mockEnvironmentId, userId: mockUserId, device: mockDevice })
|
||||
).rejects.toThrow(new ResourceNotFoundError("environment", mockEnvironmentId));
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization is not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(
|
||||
getPersonState({ environmentId: mockEnvironmentId, userId: mockUserId, device: mockDevice })
|
||||
).rejects.toThrow(new ResourceNotFoundError("organization", mockEnvironmentId));
|
||||
});
|
||||
|
||||
test("should return person state if contact exists", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization);
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(mockResolvedContactFromGetContactByUserId);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
|
||||
|
||||
const result = await getPersonState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result.state.contactId).toBe(mockContactId);
|
||||
expect(result.state.userId).toBe(mockUserId);
|
||||
expect(result.state.segments).toEqual([]);
|
||||
expect(result.state.displays).toEqual([]);
|
||||
expect(result.state.responses).toEqual([]);
|
||||
expect(result.state.lastDisplayAt).toBeNull();
|
||||
expect(result.revalidateProps).toBeUndefined();
|
||||
expect(prisma.contact.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should create contact and return person state if contact does not exist", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization);
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(null);
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue(mockResolvedContactFromPrismaCreate as any);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
|
||||
|
||||
const result = await getPersonState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(prisma.contact.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key: "userId", environmentId: mockEnvironmentId } },
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.state.contactId).toBe(mockContactId);
|
||||
expect(result.state.userId).toBe(mockUserId);
|
||||
expect(result.state.segments).toEqual(["segment1"]);
|
||||
expect(result.revalidateProps).toEqual({ contactId: mockContactId, revalidate: true });
|
||||
});
|
||||
|
||||
test("should correctly map displays and responses", async () => {
|
||||
const displayDate = new Date();
|
||||
const mockDisplays = [
|
||||
{ surveyId: "survey1", createdAt: displayDate },
|
||||
{ surveyId: "survey2", createdAt: new Date(displayDate.getTime() - 1000) },
|
||||
];
|
||||
const mockResponses = [{ surveyId: "survey1" }, { surveyId: "survey3" }];
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment as TEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as TOrganization);
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(mockResolvedContactFromGetContactByUserId);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponses as any);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplays as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
|
||||
|
||||
const result = await getPersonState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result.state.displays).toEqual(
|
||||
mockDisplays.map((d) => ({ surveyId: d.surveyId, createdAt: d.createdAt }))
|
||||
);
|
||||
expect(result.state.responses).toEqual(mockResponses.map((r) => r.surveyId));
|
||||
expect(result.state.lastDisplayAt).toEqual(displayDate);
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getContactAttributes } from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/attributes";
|
||||
import { getContactByUserId } from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/lib/contact";
|
||||
import { getPersonSegmentIds } from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
*/
|
||||
export const getPersonState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
device,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
device: "phone" | "desktop";
|
||||
}): Promise<{
|
||||
state: TJsPersonState["data"];
|
||||
revalidateProps?: { contactId: string; revalidate: boolean };
|
||||
}> => {
|
||||
let revalidatePerson = false;
|
||||
const environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(`environment`, environmentId);
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(`organization`, environmentId);
|
||||
}
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
create: [
|
||||
{
|
||||
attributeKey: {
|
||||
connect: { key_environmentId: { key: "userId", environmentId } },
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePerson = true;
|
||||
}
|
||||
|
||||
const contactResponses = await prisma.response.findMany({
|
||||
where: {
|
||||
contactId: contact.id,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const contactDisplays = await prisma.display.findMany({
|
||||
where: {
|
||||
contactId: contact.id,
|
||||
},
|
||||
select: {
|
||||
surveyId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get contact attributes for optimized segment evaluation
|
||||
const contactAttributes = await getContactAttributes(contact.id);
|
||||
|
||||
const segments = await getPersonSegmentIds(environmentId, contact.id, userId, contactAttributes, device);
|
||||
|
||||
const sortedContactDisplaysDate = contactDisplays?.toSorted(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
)[0]?.createdAt;
|
||||
|
||||
// If the person exists, return the persons's state
|
||||
const userState: TJsPersonState["data"] = {
|
||||
contactId: contact.id,
|
||||
userId,
|
||||
segments,
|
||||
displays:
|
||||
contactDisplays?.map((display) => ({
|
||||
surveyId: display.surveyId,
|
||||
createdAt: display.createdAt,
|
||||
})) ?? [],
|
||||
responses: contactResponses?.map((response) => response.surveyId) ?? [],
|
||||
lastDisplayAt: contactDisplays?.length > 0 ? sortedContactDisplaysDate : null,
|
||||
};
|
||||
|
||||
return {
|
||||
state: userState,
|
||||
revalidateProps: revalidatePerson ? { contactId: contact.id, revalidate: true } : undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
import {
|
||||
getPersonSegmentIds,
|
||||
getSegments,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/user/lib/segments";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
|
||||
// Mock the cache functions
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/cache", () => ({
|
||||
createCacheKey: {
|
||||
environment: {
|
||||
segments: vi.fn((environmentId) => `segments-${environmentId}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock React cache
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
evaluateSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
segment: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "bbn7e47f6etoai6usxezxd4a";
|
||||
const mockContactId = "cworhmq5yqvnb0tsfw9yka4b";
|
||||
const mockContactUserId = "xrgbcxn5y9so92igacthutfw";
|
||||
const mockDeviceType = "desktop";
|
||||
|
||||
const mockSegmentsData = [
|
||||
{
|
||||
id: "segment1",
|
||||
filters: [{}] as TBaseFilter[],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
description: null,
|
||||
title: "Segment 1",
|
||||
isPrivate: false,
|
||||
},
|
||||
{
|
||||
id: "segment2",
|
||||
filters: [{}] as TBaseFilter[],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: mockEnvironmentId,
|
||||
description: null,
|
||||
title: "Segment 2",
|
||||
isPrivate: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockContactAttributesData = {
|
||||
attribute1: "value1",
|
||||
attribute2: "value2",
|
||||
};
|
||||
|
||||
describe("segments lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSegments", () => {
|
||||
test("should return segments successfully", async () => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData);
|
||||
|
||||
const result = await getSegments(mockEnvironmentId);
|
||||
|
||||
expect(prisma.segment.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: mockEnvironmentId },
|
||||
select: { id: true, filters: true },
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSegmentsData);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const mockErrorMessage = "Prisma error";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.segment.findMany).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getSegments(mockEnvironmentId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
const genericError = new Error("Test Generic Error");
|
||||
|
||||
vi.mocked(prisma.segment.findMany).mockRejectedValueOnce(genericError);
|
||||
await expect(getSegments(mockEnvironmentId)).rejects.toThrow("Test Generic Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPersonSegmentIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData); // Mock for getSegments call
|
||||
});
|
||||
|
||||
test("should return person segment IDs successfully", async () => {
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true); // All segments evaluate to true
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockContactAttributesData,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
|
||||
mockSegmentsData.forEach((segment) => {
|
||||
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||
{
|
||||
attributes: mockContactAttributesData,
|
||||
deviceType: mockDeviceType,
|
||||
environmentId: mockEnvironmentId,
|
||||
contactId: mockContactId,
|
||||
userId: mockContactUserId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
||||
});
|
||||
|
||||
test("should return empty array if no segments exist", async () => {
|
||||
// @ts-expect-error -- this is a valid test case to check for null
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(null); // No segments
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockContactAttributesData,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return empty array if segments is null", async () => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(null as any); // segments is null
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockContactAttributesData,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return only matching segment IDs", async () => {
|
||||
vi.mocked(evaluateSegment)
|
||||
.mockResolvedValueOnce(true) // First segment matches
|
||||
.mockResolvedValueOnce(false); // Second segment does not match
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockContactAttributesData,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([mockSegmentsData[0].id]);
|
||||
expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsUserIdentifyInput } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getPersonState } from "./lib/person-state";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
const { environmentId, userId } = params;
|
||||
|
||||
// Validate input
|
||||
const syncInputValidation = ZJsUserIdentifyInput.safeParse({
|
||||
environmentId,
|
||||
userId,
|
||||
});
|
||||
if (!syncInputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(syncInputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { device } = userAgent(req);
|
||||
const deviceType = device ? "phone" : "desktop";
|
||||
|
||||
try {
|
||||
const personState = await getPersonState({
|
||||
environmentId,
|
||||
userId,
|
||||
device: deviceType,
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(personState.state, true),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse(err.resourceType, err.resourceId),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ err, url: req.url }, "Error fetching person state");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
err.message ?? "Unable to fetch person state",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error fetching person state");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
`Unable to complete response: ${error.message}`,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
import { getPersonSegmentIds, getSegments } from "./segments";
|
||||
|
||||
// Mock the cache functions
|
||||
@@ -18,8 +18,8 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
evaluateSegment: vi.fn(),
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
|
||||
segmentFilterToPrismaQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -27,6 +27,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
segment: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -42,8 +45,7 @@ vi.mock("react", async () => {
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
const mockContactId = "test-contact-id";
|
||||
const mockContactUserId = "test-contact-user-id";
|
||||
const mockAttributes = { email: "test@example.com" };
|
||||
const mockDeviceType = "desktop";
|
||||
const mockDeviceType = "desktop" as const;
|
||||
|
||||
const mockSegmentsData = [
|
||||
{ id: "segment1", filters: [{}] as TBaseFilter[] },
|
||||
@@ -61,7 +63,9 @@ describe("segments lib", () => {
|
||||
|
||||
describe("getSegments", () => {
|
||||
test("should return segments successfully", async () => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData as any);
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
|
||||
const result = await getSegments(mockEnvironmentId);
|
||||
|
||||
@@ -94,17 +98,26 @@ describe("segments lib", () => {
|
||||
|
||||
describe("getPersonSegmentIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(mockSegmentsData as any); // Mock for getSegments call
|
||||
vi.mocked(prisma.segment.findMany).mockResolvedValue(
|
||||
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
|
||||
);
|
||||
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
|
||||
ok: true,
|
||||
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return person segment IDs successfully", async () => {
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true); // All segments evaluate to true
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockAttributes,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
@@ -114,19 +127,8 @@ describe("segments lib", () => {
|
||||
select: { id: true, filters: true },
|
||||
});
|
||||
|
||||
expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
mockSegmentsData.forEach((segment) => {
|
||||
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||
{
|
||||
attributes: mockAttributes,
|
||||
deviceType: mockDeviceType,
|
||||
environmentId: mockEnvironmentId,
|
||||
contactId: mockContactId,
|
||||
userId: mockContactUserId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
});
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
|
||||
});
|
||||
|
||||
@@ -137,36 +139,34 @@ describe("segments lib", () => {
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockAttributes,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return empty array if segments exist but none match", async () => {
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false); // All segments evaluate to false
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockAttributes,
|
||||
mockDeviceType
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
});
|
||||
|
||||
test("should call validateInputs with correct parameters", async () => {
|
||||
await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockAttributes,
|
||||
mockDeviceType
|
||||
);
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>);
|
||||
|
||||
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[mockEnvironmentId, expect.anything()],
|
||||
[mockContactId, expect.anything()],
|
||||
@@ -175,20 +175,24 @@ describe("segments lib", () => {
|
||||
});
|
||||
|
||||
test("should return only matching segment IDs", async () => {
|
||||
vi.mocked(evaluateSegment)
|
||||
.mockResolvedValueOnce(true) // First segment matches
|
||||
.mockResolvedValueOnce(false); // Second segment does not match
|
||||
// First segment matches, second doesn't
|
||||
vi.mocked(prisma.contact.findFirst)
|
||||
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
|
||||
typeof prisma.contact,
|
||||
unknown,
|
||||
"findFirst"
|
||||
>) // First segment matches
|
||||
.mockResolvedValueOnce(null); // Second segment does not match
|
||||
|
||||
const result = await getPersonSegmentIds(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockContactUserId,
|
||||
mockAttributes,
|
||||
mockDeviceType
|
||||
);
|
||||
|
||||
expect(result).toEqual([mockSegmentsData[0].id]);
|
||||
expect(evaluateSegment).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TBaseFilter } from "@formbricks/types/segment";
|
||||
import { TBaseFilters } from "@formbricks/types/segment";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
|
||||
|
||||
export const getSegments = reactCache(
|
||||
async (environmentId: string) =>
|
||||
@@ -17,7 +17,6 @@ export const getSegments = reactCache(
|
||||
try {
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: { environmentId },
|
||||
// Include all necessary fields for evaluateSegment to work
|
||||
select: {
|
||||
id: true,
|
||||
filters: true,
|
||||
@@ -38,11 +37,51 @@ export const getSegments = reactCache(
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if a contact matches a segment using Prisma query
|
||||
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
|
||||
* Device filters are evaluated at query build time using the provided deviceType
|
||||
*/
|
||||
const isContactInSegment = async (
|
||||
contactId: string,
|
||||
segmentId: string,
|
||||
filters: TBaseFilters,
|
||||
environmentId: string,
|
||||
deviceType: "phone" | "desktop"
|
||||
): Promise<boolean> => {
|
||||
// If no filters, segment matches all contacts
|
||||
if (!filters || filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { whereClause } = queryResult.data;
|
||||
|
||||
// Check if this specific contact matches the segment filters
|
||||
const matchingContact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id: contactId,
|
||||
...whereClause,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return matchingContact !== null;
|
||||
};
|
||||
|
||||
export const getPersonSegmentIds = async (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
contactUserId: string,
|
||||
attributes: Record<string, string>,
|
||||
deviceType: "phone" | "desktop"
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
@@ -55,26 +94,16 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
const personSegments: { id: string; filters: TBaseFilter[] }[] = [];
|
||||
// Device filters are evaluated at query build time using the provided deviceType
|
||||
const segmentPromises = segments.map(async (segment) => {
|
||||
const filters = segment.filters;
|
||||
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
|
||||
return isIncluded ? segment.id : null;
|
||||
});
|
||||
|
||||
for (const segment of segments) {
|
||||
const isIncluded = await evaluateSegment(
|
||||
{
|
||||
attributes,
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId: contactId,
|
||||
userId: contactUserId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
const results = await Promise.all(segmentPromises);
|
||||
|
||||
if (isIncluded) {
|
||||
personSegments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return personSegments.map((segment) => segment.id);
|
||||
return results.filter((id): id is string => id !== null);
|
||||
} catch (error) {
|
||||
// Log error for debugging but don't throw to prevent "segments is not iterable" error
|
||||
logger.warn(
|
||||
|
||||
@@ -12,9 +12,13 @@ vi.mock("@/lib/cache", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/lib/attributes", () => ({
|
||||
updateAttributes: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/lib/attributes", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ee/contacts/lib/attributes")>();
|
||||
return {
|
||||
...actual,
|
||||
updateAttributes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -121,7 +125,7 @@ describe("updateUser", () => {
|
||||
lastDisplayAt: null,
|
||||
})
|
||||
);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should update existing contact attributes", async () => {
|
||||
@@ -137,7 +141,7 @@ describe("updateUser", () => {
|
||||
newAttributes
|
||||
);
|
||||
expect(result.state.data?.language).toBe("en");
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.messages).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should not update attributes if they are the same", async () => {
|
||||
@@ -152,7 +156,9 @@ describe("updateUser", () => {
|
||||
test("should return messages from updateAttributes if any", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any);
|
||||
const newAttributes = { company: "Formbricks" };
|
||||
const updateMessages = ["Attribute 'company' created."];
|
||||
const updateMessages = [
|
||||
{ code: "new_attribute_created", params: { key: "company", dataType: "string" } },
|
||||
];
|
||||
vi.mocked(updateAttributes).mockResolvedValue({ success: true, messages: updateMessages });
|
||||
|
||||
const result = await updateUser(mockEnvironmentId, mockUserId, "desktop", newAttributes);
|
||||
@@ -163,30 +169,18 @@ describe("updateUser", () => {
|
||||
mockEnvironmentId,
|
||||
newAttributes
|
||||
);
|
||||
expect(result.messages).toEqual(updateMessages);
|
||||
expect(result.messages).toEqual(["Created new attribute 'company' with type 'string'"]);
|
||||
});
|
||||
|
||||
test("should use device type 'phone'", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any);
|
||||
await updateUser(mockEnvironmentId, mockUserId, "phone");
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
{ userId: mockUserId, email: "test@example.com" },
|
||||
"phone"
|
||||
);
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(mockEnvironmentId, mockContactId, mockUserId, "phone");
|
||||
});
|
||||
|
||||
test("should use device type 'desktop'", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData as any);
|
||||
await updateUser(mockEnvironmentId, mockUserId, "desktop");
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
{ userId: mockUserId, email: "test@example.com" },
|
||||
"desktop"
|
||||
);
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(mockEnvironmentId, mockContactId, mockUserId, "desktop");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { formatAttributeMessage, updateAttributes } from "@/modules/ee/contacts/lib/attributes";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
@@ -110,19 +111,18 @@ const buildUserStateFromContact = async (
|
||||
contactData: NonNullable<Awaited<ReturnType<typeof getContactWithFullData>>>,
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
device: "phone" | "desktop",
|
||||
attributes: Record<string, string>
|
||||
device: "phone" | "desktop"
|
||||
) => {
|
||||
// Get segments (only remaining external call)
|
||||
// Ensure segments is always an array to prevent "segments is not iterable" error
|
||||
let segments: string[] = [];
|
||||
try {
|
||||
segments = await getPersonSegmentIds(environmentId, contactData.id, userId, attributes, device);
|
||||
segments = await getPersonSegmentIds(environmentId, contactData.id, userId, device);
|
||||
// Double-check that segments is actually an array
|
||||
if (!Array.isArray(segments)) {
|
||||
segments = [];
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// If segments fetching fails, use empty array as fallback
|
||||
segments = [];
|
||||
}
|
||||
@@ -151,8 +151,8 @@ export const updateUser = async (
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
device: "phone" | "desktop",
|
||||
attributes?: Record<string, string>
|
||||
): Promise<{ state: TJsPersonState; messages?: string[] }> => {
|
||||
attributes?: TContactAttributesInput
|
||||
): Promise<{ state: TJsPersonState; messages?: string[]; errors?: string[] }> => {
|
||||
// Cached environment validation (rarely changes)
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
@@ -177,6 +177,7 @@ export const updateUser = async (
|
||||
);
|
||||
|
||||
let messages: string[] = [];
|
||||
let errors: string[] = [];
|
||||
let language = contactAttributes.language;
|
||||
|
||||
// Handle attribute updates efficiently
|
||||
@@ -188,40 +189,21 @@ export const updateUser = async (
|
||||
const {
|
||||
success,
|
||||
messages: updateAttrMessages,
|
||||
ignoreEmailAttribute,
|
||||
errors: updateAttrErrors,
|
||||
} = await updateAttributes(contactData.id, userId, environmentId, attributes);
|
||||
|
||||
messages = updateAttrMessages ?? [];
|
||||
messages = updateAttrMessages?.map(formatAttributeMessage) ?? [];
|
||||
errors = updateAttrErrors?.map(formatAttributeMessage) ?? [];
|
||||
|
||||
// Update local attributes if successful
|
||||
if (success) {
|
||||
let attributesToUpdate = { ...attributes };
|
||||
|
||||
if (ignoreEmailAttribute) {
|
||||
const { email, ...rest } = attributes;
|
||||
attributesToUpdate = rest;
|
||||
}
|
||||
|
||||
contactAttributes = {
|
||||
...contactAttributes,
|
||||
...attributesToUpdate,
|
||||
};
|
||||
|
||||
if (attributes.language) {
|
||||
language = attributes.language;
|
||||
}
|
||||
// Update language if provided (used in response state)
|
||||
if (success && attributes.language) {
|
||||
language = String(attributes.language);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build user state from already-fetched data (no additional query needed)
|
||||
const userStateData = await buildUserStateFromContact(
|
||||
contactData,
|
||||
environmentId,
|
||||
userId,
|
||||
device,
|
||||
contactAttributes
|
||||
);
|
||||
const userStateData = await buildUserStateFromContact(contactData, environmentId, userId, device);
|
||||
|
||||
return {
|
||||
state: {
|
||||
@@ -231,6 +213,7 @@ export const updateUser = async (
|
||||
},
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
|
||||
},
|
||||
messages,
|
||||
messages: messages.length > 0 ? messages : undefined,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ const mockEnvironmentId = "test-environment-id";
|
||||
const mockUserId = "test-user-id";
|
||||
const mockContactId = "test-contact-id";
|
||||
const mockDevice = "desktop";
|
||||
const mockAttributes = { email: "test@example.com" };
|
||||
|
||||
describe("getUserState", () => {
|
||||
beforeEach(() => {
|
||||
@@ -45,7 +44,6 @@ describe("getUserState", () => {
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
attributes: mockAttributes,
|
||||
});
|
||||
|
||||
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
@@ -65,7 +63,6 @@ describe("getUserState", () => {
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockAttributes,
|
||||
mockDevice
|
||||
);
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
@@ -98,7 +95,6 @@ describe("getUserState", () => {
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
attributes: mockAttributes,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
@@ -129,7 +125,6 @@ describe("getUserState", () => {
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
attributes: mockAttributes,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
|
||||
@@ -33,7 +33,6 @@ const getUserStateDataOptimized = async (contactId: string) => {
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @param attributes - The contact attributes
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
@@ -43,19 +42,17 @@ export const getUserState = async ({
|
||||
userId,
|
||||
contactId,
|
||||
device,
|
||||
attributes,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
contactId: string;
|
||||
device: "phone" | "desktop";
|
||||
attributes: Record<string, string>;
|
||||
}): Promise<TJsPersonState["data"]> => {
|
||||
// Single optimized query for all contact data
|
||||
const contactData = await getUserStateDataOptimized(contactId);
|
||||
|
||||
// Get segments (this might have its own optimization)
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, attributes, device);
|
||||
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
|
||||
|
||||
// Process displays efficiently
|
||||
const displays = (contactData.displays ?? []).map((display) => ({
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateUser } from "./lib/update-user";
|
||||
|
||||
const handleError = (err: unknown, url: string): { response: Response } => {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
|
||||
}
|
||||
|
||||
if (err instanceof ValidationError) {
|
||||
return { response: responses.badRequestResponse(err.message, undefined, true) };
|
||||
}
|
||||
|
||||
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
|
||||
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
@@ -96,43 +109,34 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
let attributeUpdatesToSend: TContactAttributes | null = null;
|
||||
let attributeUpdatesToSend: TContactAttributesInput | null = null;
|
||||
if (attributes) {
|
||||
// remove userId and id from attributes
|
||||
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
|
||||
attributeUpdatesToSend = updatedAttributes;
|
||||
attributeUpdatesToSend = updatedAttributes as TContactAttributesInput;
|
||||
}
|
||||
|
||||
const { device } = userAgent(req);
|
||||
const deviceType = device ? "phone" : "desktop";
|
||||
|
||||
const { state: userState, messages } = await updateUser(
|
||||
environmentId,
|
||||
userId,
|
||||
deviceType,
|
||||
attributeUpdatesToSend ?? undefined
|
||||
);
|
||||
const {
|
||||
state: userState,
|
||||
messages,
|
||||
errors,
|
||||
} = await updateUser(environmentId, userId, deviceType, attributeUpdatesToSend ?? undefined);
|
||||
|
||||
// Build response (simplified structure)
|
||||
const responseJson: { state: TJsPersonState; messages?: string[] } = {
|
||||
const responseJson: { state: TJsPersonState; messages?: string[]; errors?: string[] } = {
|
||||
state: userState,
|
||||
...(messages && messages.length > 0 && { messages }),
|
||||
...(errors && errors.length > 0 && { errors }),
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(responseJson, true),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse(err.resourceType, err.resourceId),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
|
||||
};
|
||||
return handleError(err, req.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
|
||||
description: data.description,
|
||||
name: data.name,
|
||||
key: data.key,
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
|
||||
export const ZContactAttributeKeyCreateInput = z.object({
|
||||
key: z.string(),
|
||||
key: z.string().refine((val) => isSafeIdentifier(val), {
|
||||
message:
|
||||
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["custom"]),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
environmentId: z.string(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
@@ -12,7 +18,14 @@ export type TContactAttributeKeyCreateInput = z.infer<typeof ZContactAttributeKe
|
||||
export const ZContactAttributeKeyUpdateInput = z.object({
|
||||
description: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
key: z
|
||||
.string()
|
||||
.refine((val) => isSafeIdentifier(val), {
|
||||
message:
|
||||
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
})
|
||||
.optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;
|
||||
|
||||
@@ -177,7 +177,7 @@ describe("createContactAttributeKey", () => {
|
||||
expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
key: inputWithoutName.key,
|
||||
name: inputWithoutName.key, // Should fall back to key when name is not provided
|
||||
name: "TestKey", // formatSnakeCaseToTitleCase("testKey") capitalizes first letter
|
||||
type: inputWithoutName.type,
|
||||
description: inputWithoutName.description || "",
|
||||
environment: { connect: { id: environmentId } },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { formatSnakeCaseToTitleCase } from "@/lib/utils/safe-identifier";
|
||||
import { TContactAttributeKeyCreateInput } from "@/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
@@ -44,9 +45,10 @@ export const createContactAttributeKey = async (
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key: data.key,
|
||||
name: data.name ?? data.key,
|
||||
name: data.name ?? formatSnakeCaseToTitleCase(data.key),
|
||||
type: data.type,
|
||||
description: data.description ?? "",
|
||||
...(data.dataType && { dataType: data.dataType }),
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
|
||||
@@ -2,10 +2,481 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
|
||||
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
const EMAIL_ATTRIBUTE_KEY = "email";
|
||||
|
||||
type TExistingContact = {
|
||||
contactId: string;
|
||||
attributes: { id: string; attributeKey: { key: string }; createdAt: Date; value: string }[];
|
||||
};
|
||||
|
||||
type TContactToUpdate = {
|
||||
contactId: string;
|
||||
attributes: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
value: string;
|
||||
attributeKey: { key: string };
|
||||
}[];
|
||||
};
|
||||
|
||||
type TContactToCreate = {
|
||||
attributes: {
|
||||
value: string;
|
||||
attributeKey: { key: string };
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts user IDs and unique attribute keys from contacts
|
||||
*/
|
||||
const extractContactMetadata = (
|
||||
contacts: TContactBulkUploadContact[]
|
||||
): {
|
||||
userIdsInContacts: string[];
|
||||
attributeKeys: string[];
|
||||
} => {
|
||||
const userIdsInContacts: string[] = [];
|
||||
const attributeKeysSet = new Set<string>();
|
||||
const attributeKeys: string[] = [];
|
||||
|
||||
for (const contact of contacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (attr.attributeKey.key === "userId") {
|
||||
userIdsInContacts.push(attr.value);
|
||||
}
|
||||
|
||||
if (!attributeKeysSet.has(attr.attributeKey.key)) {
|
||||
attributeKeys.push(attr.attributeKey.key);
|
||||
attributeKeysSet.add(attr.attributeKey.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { userIdsInContacts, attributeKeys };
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a map of attribute keys to their values for type detection
|
||||
*/
|
||||
const buildAttributeValuesByKey = (contacts: TContactBulkUploadContact[]): Map<string, string[]> => {
|
||||
const attributeValuesByKey = new Map<string, string[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeValuesByKey.has(attr.attributeKey.key)) {
|
||||
attributeValuesByKey.set(attr.attributeKey.key, []);
|
||||
}
|
||||
if (attr.value.trim() !== "") {
|
||||
attributeValuesByKey.get(attr.attributeKey.key)!.push(attr.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attributeValuesByKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines data types for attribute keys based on existing keys and detected types
|
||||
*/
|
||||
const determineAttributeTypes = (
|
||||
attributeValuesByKey: Map<string, string[]>,
|
||||
existingAttributeKeys: { key: string; dataType: TContactAttributeDataType }[]
|
||||
): Map<string, TContactAttributeDataType> => {
|
||||
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
|
||||
|
||||
for (const [key, values] of attributeValuesByKey) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
|
||||
if (existingKey) {
|
||||
attributeTypeMap.set(key, existingKey.dataType);
|
||||
} else {
|
||||
const firstValue = values.find((v) => v !== "");
|
||||
const detectedType = firstValue ? detectAttributeDataType(firstValue) : "string";
|
||||
attributeTypeMap.set(key, detectedType);
|
||||
}
|
||||
}
|
||||
|
||||
return attributeTypeMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds values that fail parsing for the given data type.
|
||||
*/
|
||||
const findInvalidValues = (values: string[], dataType: TContactAttributeDataType): string[] => {
|
||||
return values.filter((value) => {
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
return (
|
||||
(dataType === "number" && columns.valueNumber === null) ||
|
||||
(dataType === "date" && columns.valueDate === null)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a human-readable error message for invalid attribute values.
|
||||
*/
|
||||
const buildInvalidValuesError = (
|
||||
key: string,
|
||||
dataType: TContactAttributeDataType,
|
||||
invalidValues: string[]
|
||||
): string => {
|
||||
const sampleInvalid = invalidValues.slice(0, 3).join(", ");
|
||||
const additionalCount = invalidValues.length - 3;
|
||||
const suffix = additionalCount > 0 ? ` (and ${additionalCount.toString()} more)` : "";
|
||||
return `Attribute "${key}" is typed as "${dataType}" but received invalid values: ${sampleInvalid}${suffix}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates attribute values against their expected types.
|
||||
* For NEW keys (not yet in DB): downgrades to string if values are mixed/invalid.
|
||||
* For EXISTING keys: returns errors for invalid values (the type is already set in the DB and must be respected).
|
||||
*/
|
||||
const validateAndAdjustAttributeTypes = (
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>,
|
||||
attributeValuesByKey: Map<string, string[]>,
|
||||
existingAttributeKeys: { key: string; dataType: TContactAttributeDataType }[]
|
||||
): { existingKeyErrors: string[] } => {
|
||||
const existingKeySet = new Set(existingAttributeKeys.map((ak) => ak.key));
|
||||
const newKeyWarnings: string[] = [];
|
||||
const existingKeyErrors: string[] = [];
|
||||
|
||||
for (const [key, dataType] of attributeTypeMap) {
|
||||
if (dataType === "string") continue;
|
||||
|
||||
const values = attributeValuesByKey.get(key) || [];
|
||||
const invalidValues = findInvalidValues(values, dataType);
|
||||
|
||||
if (invalidValues.length === 0) continue;
|
||||
|
||||
if (existingKeySet.has(key)) {
|
||||
existingKeyErrors.push(buildInvalidValuesError(key, dataType, invalidValues));
|
||||
} else {
|
||||
attributeTypeMap.set(key, "string");
|
||||
newKeyWarnings.push(
|
||||
`New attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newKeyWarnings.length > 0) {
|
||||
logger.warn({ errors: newKeyWarnings }, "Type validation warnings during bulk upload");
|
||||
}
|
||||
|
||||
return { existingKeyErrors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a map from email to contact data for existing contacts
|
||||
*/
|
||||
const buildExistingContactMap = (
|
||||
existingContactsByEmail: {
|
||||
id: string;
|
||||
attributes: { id: string; attributeKey: { key: string }; createdAt: Date; value: string }[];
|
||||
}[]
|
||||
): Map<string, TExistingContact> => {
|
||||
const contactMap = new Map<string, TExistingContact>();
|
||||
|
||||
for (const contact of existingContactsByEmail) {
|
||||
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === EMAIL_ATTRIBUTE_KEY);
|
||||
|
||||
if (emailAttr) {
|
||||
contactMap.set(emailAttr.value, {
|
||||
contactId: contact.id,
|
||||
attributes: contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
attributeKey: { key: attr.attributeKey.key },
|
||||
createdAt: attr.createdAt,
|
||||
value: attr.value,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return contactMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a contact that exists in the database to determine if it needs updating
|
||||
*/
|
||||
const processExistingContact = (
|
||||
contact: TContactBulkUploadContact,
|
||||
existingContact: TExistingContact,
|
||||
existingUserIds: { value: string }[],
|
||||
idx: number,
|
||||
contactIdxWithConflictingUserIds: number[],
|
||||
contactsToUpdate: TContactToUpdate[],
|
||||
filteredContacts: TContactBulkUploadContact[]
|
||||
): void => {
|
||||
const existingAttributesByKey = new Map(
|
||||
existingContact.attributes.map((attr) => [attr.attributeKey.key, attr.value])
|
||||
);
|
||||
|
||||
const attributesToUpdate = contact.attributes.filter(
|
||||
(attr) => existingAttributesByKey.get(attr.attributeKey.key) !== attr.value
|
||||
);
|
||||
|
||||
if (attributesToUpdate.length === 0) {
|
||||
filteredContacts.push(contact);
|
||||
return;
|
||||
}
|
||||
|
||||
const userIdAttr = attributesToUpdate.find((attr) => attr.attributeKey.key === "userId");
|
||||
if (userIdAttr && existingUserIds.some((u) => u.value === userIdAttr.value)) {
|
||||
contactIdxWithConflictingUserIds.push(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredContacts.push(contact);
|
||||
contactsToUpdate.push({
|
||||
contactId: existingContact.contactId,
|
||||
attributes: attributesToUpdate.map((attr) => {
|
||||
const existingAttr = existingContact.attributes.find(
|
||||
(a) => a.attributeKey.key === attr.attributeKey.key
|
||||
);
|
||||
|
||||
return existingAttr
|
||||
? {
|
||||
id: existingAttr.id,
|
||||
createdAt: existingAttr.createdAt,
|
||||
value: attr.value,
|
||||
attributeKey: attr.attributeKey,
|
||||
}
|
||||
: { id: createId(), createdAt: new Date(), value: attr.value, attributeKey: attr.attributeKey };
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a new contact to determine if it can be created
|
||||
*/
|
||||
const processNewContact = (
|
||||
contact: TContactBulkUploadContact,
|
||||
existingUserIds: { value: string }[],
|
||||
idx: number,
|
||||
contactIdxWithConflictingUserIds: number[],
|
||||
contactsToCreate: TContactToCreate[],
|
||||
filteredContacts: TContactBulkUploadContact[]
|
||||
): void => {
|
||||
const userIdAttr = contact.attributes.find((attr) => attr.attributeKey.key === "userId");
|
||||
|
||||
if (userIdAttr && existingUserIds.some((u) => u.value === userIdAttr.value)) {
|
||||
contactIdxWithConflictingUserIds.push(idx);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredContacts.push(contact);
|
||||
contactsToCreate.push(contact);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects missing attribute keys and keys needing name updates
|
||||
*/
|
||||
const collectAttributeKeyChanges = (
|
||||
filteredContacts: TContactBulkUploadContact[],
|
||||
attributeKeyMap: Record<string, string>,
|
||||
existingAttributeKeys: { key: string; name: string | null; dataType: TContactAttributeDataType }[]
|
||||
): {
|
||||
missingKeysMap: Map<string, { key: string; name: string }>;
|
||||
attributeKeyNameUpdates: Map<string, { key: string; name: string }>;
|
||||
} => {
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (attributeKeyMap[attr.attributeKey.key]) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
} else {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { missingKeysMap, attributeKeyNameUpdates };
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the map of keys to upsert with their data types
|
||||
*/
|
||||
const buildKeysToUpsert = (
|
||||
missingKeysMap: Map<string, { key: string; name: string }>,
|
||||
attributeKeyNameUpdates: Map<string, { key: string; name: string }>,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>,
|
||||
existingAttributeKeys: { key: string; dataType: TContactAttributeDataType }[]
|
||||
): Map<string, { key: string; name: string; dataType: TContactAttributeDataType }> => {
|
||||
const keysToUpsert = new Map<string, { key: string; name: string; dataType: TContactAttributeDataType }>();
|
||||
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
const dataType = attributeTypeMap.get(key) ?? "string";
|
||||
keysToUpsert.set(key, { key: value.key, name: value.name, dataType });
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
|
||||
const dataType = existingKey?.dataType ?? "string";
|
||||
keysToUpsert.set(key, { key: value.key, name: value.name, dataType });
|
||||
}
|
||||
|
||||
return keysToUpsert;
|
||||
};
|
||||
|
||||
type TAttributeUpsertData = {
|
||||
id: string;
|
||||
contactId: string;
|
||||
attributeKeyId: string;
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares attribute data for new contacts
|
||||
*/
|
||||
const prepareAttributesForNewContacts = (
|
||||
contactsToCreate: TContactToCreate[],
|
||||
newContacts: { id: string; environmentId: string }[],
|
||||
attributeKeyMap: Record<string, string>,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>
|
||||
): TAttributeUpsertData[] => {
|
||||
return contactsToCreate.flatMap((contact, idx) =>
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
contactId: newContacts[idx].id,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares attribute data for existing contacts
|
||||
*/
|
||||
const prepareAttributesForExistingContacts = (
|
||||
contactsToUpdate: TContactToUpdate[],
|
||||
attributeKeyMap: Record<string, string>,
|
||||
attributeTypeMap: Map<string, TContactAttributeDataType>
|
||||
): TAttributeUpsertData[] => {
|
||||
return contactsToUpdate.flatMap((contact) =>
|
||||
contact.attributes.map((attr) => {
|
||||
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
|
||||
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
|
||||
|
||||
return {
|
||||
id: attr.id,
|
||||
contactId: contact.contactId,
|
||||
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
createdAt: attr.createdAt,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Upserts attribute keys in batches using raw SQL
|
||||
*/
|
||||
const upsertAttributeKeysInBatches = async (
|
||||
tx: Prisma.TransactionClient,
|
||||
keysToUpsert: Map<string, { key: string; name: string; dataType: TContactAttributeDataType }>,
|
||||
environmentId: string,
|
||||
attributeKeyMap: Record<string, string>
|
||||
): Promise<void> => {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
|
||||
${environmentId},
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
|
||||
NOW(),
|
||||
NOW()
|
||||
ON CONFLICT ("key", "environmentId")
|
||||
DO UPDATE SET
|
||||
"name" = EXCLUDED."name",
|
||||
"updated_at" = NOW()
|
||||
RETURNING "id", "key"
|
||||
`;
|
||||
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upserts contact attributes in batches using raw SQL
|
||||
*/
|
||||
const upsertAttributesInBatches = async (
|
||||
tx: Prisma.TransactionClient,
|
||||
attributesToUpsert: TAttributeUpsertData[]
|
||||
): Promise<void> => {
|
||||
for (let i = 0; i < attributesToUpsert.length; i += BATCH_SIZE) {
|
||||
const batch = attributesToUpsert.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const ids = batch.map((a) => a.id);
|
||||
const createdAts = batch.map((a) => a.createdAt.toISOString());
|
||||
const updatedAts = batch.map((a) => a.updatedAt.toISOString());
|
||||
const contactIds = batch.map((a) => a.contactId);
|
||||
const values = batch.map((a) => a.value);
|
||||
const valueNumbers = batch.map((a) => (a.valueNumber === null ? null : String(a.valueNumber)));
|
||||
const valueDates = batch.map((a) => (a.valueDate ? a.valueDate.toISOString() : null));
|
||||
const attributeKeyIds = batch.map((a) => a.attributeKeyId);
|
||||
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "ContactAttribute" (
|
||||
"id", "created_at", "updated_at", "contactId", "value", "valueNumber", "valueDate", "attributeKeyId"
|
||||
)
|
||||
SELECT
|
||||
unnest(${ids}::text[]),
|
||||
unnest(${createdAts}::text[])::timestamp,
|
||||
unnest(${updatedAts}::text[])::timestamp,
|
||||
unnest(${contactIds}::text[]),
|
||||
unnest(${values}::text[]),
|
||||
unnest(${valueNumbers}::text[])::double precision,
|
||||
unnest(${valueDates}::text[])::timestamp,
|
||||
unnest(${attributeKeyIds}::text[])
|
||||
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
|
||||
"value" = EXCLUDED."value",
|
||||
"valueNumber" = EXCLUDED."valueNumber",
|
||||
"valueDate" = EXCLUDED."valueDate",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertBulkContacts = async (
|
||||
contacts: TContactBulkUploadContact[],
|
||||
environmentId: string,
|
||||
@@ -18,43 +489,16 @@ export const upsertBulkContacts = async (
|
||||
ApiErrorResponseV2
|
||||
>
|
||||
> => {
|
||||
const emailAttributeKey = "email";
|
||||
const contactIdxWithConflictingUserIds: number[] = [];
|
||||
|
||||
let userIdsInContacts: string[] = [];
|
||||
let attributeKeysSet: Set<string> = new Set();
|
||||
let attributeKeys: string[] = [];
|
||||
|
||||
// both can be done with a single loop:
|
||||
contacts.forEach((contact) => {
|
||||
contact.attributes.forEach((attr) => {
|
||||
if (attr.attributeKey.key === "userId") {
|
||||
userIdsInContacts.push(attr.value);
|
||||
}
|
||||
|
||||
if (!attributeKeysSet.has(attr.attributeKey.key)) {
|
||||
attributeKeys.push(attr.attributeKey.key);
|
||||
}
|
||||
|
||||
// Add the attribute key to the set
|
||||
attributeKeysSet.add(attr.attributeKey.key);
|
||||
});
|
||||
});
|
||||
const { userIdsInContacts, attributeKeys } = extractContactMetadata(contacts);
|
||||
|
||||
const [existingUserIds, existingContactsByEmail, existingAttributeKeys] = await Promise.all([
|
||||
prisma.contactAttribute.findMany({
|
||||
where: {
|
||||
attributeKey: {
|
||||
environmentId,
|
||||
key: "userId",
|
||||
},
|
||||
value: {
|
||||
in: userIdsInContacts,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
value: true,
|
||||
attributeKey: { environmentId, key: "userId" },
|
||||
value: { in: userIdsInContacts },
|
||||
},
|
||||
select: { value: true },
|
||||
}),
|
||||
|
||||
prisma.contact.findMany({
|
||||
@@ -62,7 +506,7 @@ export const upsertBulkContacts = async (
|
||||
environmentId,
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: emailAttributeKey },
|
||||
attributeKey: { key: EMAIL_ATTRIBUTE_KEY },
|
||||
value: { in: parsedEmails },
|
||||
},
|
||||
},
|
||||
@@ -81,146 +525,76 @@ export const upsertBulkContacts = async (
|
||||
}),
|
||||
|
||||
prisma.contactAttributeKey.findMany({
|
||||
where: {
|
||||
key: { in: attributeKeys },
|
||||
environmentId,
|
||||
},
|
||||
where: { key: { in: attributeKeys }, environmentId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Build a map from email to contact id (if the email attribute exists)
|
||||
const contactMap = new Map<
|
||||
string,
|
||||
{
|
||||
contactId: string;
|
||||
attributes: { id: string; attributeKey: { key: string }; createdAt: Date; value: string }[];
|
||||
}
|
||||
>();
|
||||
// Validate new attribute keys are safe identifiers before proceeding
|
||||
const existingKeySet = new Set(existingAttributeKeys.map((ak) => ak.key));
|
||||
const invalidNewKeys = attributeKeys.filter((key) => !existingKeySet.has(key) && !isSafeIdentifier(key));
|
||||
|
||||
existingContactsByEmail.forEach((contact) => {
|
||||
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === emailAttributeKey);
|
||||
if (invalidNewKeys.length > 0) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "attributes",
|
||||
issue: `Invalid attribute key(s): ${invalidNewKeys.join(", ")}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (emailAttr) {
|
||||
contactMap.set(emailAttr.value, {
|
||||
contactId: contact.id,
|
||||
attributes: contact.attributes.map((attr) => ({
|
||||
id: attr.id,
|
||||
attributeKey: { key: attr.attributeKey.key },
|
||||
createdAt: attr.createdAt,
|
||||
value: attr.value,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
// Type Detection Phase
|
||||
const attributeValuesByKey = buildAttributeValuesByKey(contacts);
|
||||
const attributeTypeMap = determineAttributeTypes(attributeValuesByKey, existingAttributeKeys);
|
||||
const { existingKeyErrors } = validateAndAdjustAttributeTypes(
|
||||
attributeTypeMap,
|
||||
attributeValuesByKey,
|
||||
existingAttributeKeys
|
||||
);
|
||||
|
||||
if (existingKeyErrors.length > 0) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: existingKeyErrors.map((issue) => ({
|
||||
field: "attributes",
|
||||
issue,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Build contact lookup map
|
||||
const contactMap = buildExistingContactMap(existingContactsByEmail);
|
||||
|
||||
// Split contacts into ones to update and ones to create
|
||||
const contactsToUpdate: {
|
||||
contactId: string;
|
||||
attributes: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
value: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
}[];
|
||||
}[] = [];
|
||||
|
||||
const contactsToCreate: {
|
||||
attributes: {
|
||||
value: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
}[];
|
||||
}[] = [];
|
||||
|
||||
let filteredContacts: TContactBulkUploadContact[] = [];
|
||||
const contactsToUpdate: TContactToUpdate[] = [];
|
||||
const contactsToCreate: TContactToCreate[] = [];
|
||||
const filteredContacts: TContactBulkUploadContact[] = [];
|
||||
|
||||
contacts.forEach((contact, idx) => {
|
||||
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === emailAttributeKey);
|
||||
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === EMAIL_ATTRIBUTE_KEY);
|
||||
const existingContact = emailAttr ? contactMap.get(emailAttr.value) : undefined;
|
||||
|
||||
if (emailAttr && contactMap.has(emailAttr.value)) {
|
||||
// if all the attributes passed are the same as the existing attributes, skip the update:
|
||||
const existingContact = contactMap.get(emailAttr.value);
|
||||
if (existingContact) {
|
||||
// Create maps of existing attributes by key
|
||||
const existingAttributesByKey = new Map(
|
||||
existingContact.attributes.map((attr) => [attr.attributeKey.key, attr.value])
|
||||
);
|
||||
|
||||
// Determine which attributes need updating by comparing values.
|
||||
const attributesToUpdate = contact.attributes.filter(
|
||||
(attr) => existingAttributesByKey.get(attr.attributeKey.key) !== attr.value
|
||||
);
|
||||
|
||||
// Check if any attributes need updating
|
||||
const needsUpdate = attributesToUpdate.length > 0;
|
||||
|
||||
if (!needsUpdate) {
|
||||
filteredContacts.push(contact);
|
||||
// No attributes need to be updated
|
||||
return;
|
||||
}
|
||||
|
||||
// if the attributes to update have a userId that exists in the db, we need to skip the update
|
||||
const userIdAttr = attributesToUpdate.find((attr) => attr.attributeKey.key === "userId");
|
||||
|
||||
if (userIdAttr) {
|
||||
const existingUserId = existingUserIds.find(
|
||||
(existingUserId) => existingUserId.value === userIdAttr.value
|
||||
);
|
||||
|
||||
if (existingUserId) {
|
||||
contactIdxWithConflictingUserIds.push(idx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filteredContacts.push(contact);
|
||||
contactsToUpdate.push({
|
||||
contactId: existingContact.contactId,
|
||||
attributes: attributesToUpdate.map((attr) => {
|
||||
const existingAttr = existingContact.attributes.find(
|
||||
(a) => a.attributeKey.key === attr.attributeKey.key
|
||||
);
|
||||
|
||||
if (!existingAttr) {
|
||||
return {
|
||||
id: createId(),
|
||||
createdAt: new Date(),
|
||||
value: attr.value,
|
||||
attributeKey: attr.attributeKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: existingAttr.id,
|
||||
createdAt: existingAttr.createdAt,
|
||||
value: attr.value,
|
||||
attributeKey: attr.attributeKey,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (existingContact) {
|
||||
processExistingContact(
|
||||
contact,
|
||||
existingContact,
|
||||
existingUserIds,
|
||||
idx,
|
||||
contactIdxWithConflictingUserIds,
|
||||
contactsToUpdate,
|
||||
filteredContacts
|
||||
);
|
||||
} else {
|
||||
// There can't be a case where the emailAttr is not defined since that should be caught by zod.
|
||||
|
||||
// if the contact has a userId that already exists in the db, we need to skip the create
|
||||
const userIdAttr = contact.attributes.find((attr) => attr.attributeKey.key === "userId");
|
||||
if (userIdAttr) {
|
||||
const existingUserId = existingUserIds.find(
|
||||
(existingUserId) => existingUserId.value === userIdAttr.value
|
||||
);
|
||||
|
||||
if (existingUserId) {
|
||||
contactIdxWithConflictingUserIds.push(idx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filteredContacts.push(contact);
|
||||
contactsToCreate.push(contact);
|
||||
processNewContact(
|
||||
contact,
|
||||
existingUserIds,
|
||||
idx,
|
||||
contactIdxWithConflictingUserIds,
|
||||
contactsToCreate,
|
||||
filteredContacts
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -233,133 +607,55 @@ export const upsertBulkContacts = async (
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Check for missing attribute keys and create them if needed.
|
||||
const missingKeysMap = new Map<string, { key: string; name: string }>();
|
||||
const attributeKeyNameUpdates = new Map<string, { key: string; name: string }>();
|
||||
// Collect missing keys and name updates
|
||||
const { missingKeysMap, attributeKeyNameUpdates } = collectAttributeKeyChanges(
|
||||
filteredContacts,
|
||||
attributeKeyMap,
|
||||
existingAttributeKeys
|
||||
);
|
||||
|
||||
for (const contact of filteredContacts) {
|
||||
for (const attr of contact.attributes) {
|
||||
if (!attributeKeyMap[attr.attributeKey.key]) {
|
||||
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
|
||||
} else {
|
||||
// Check if the name has changed for existing attribute keys
|
||||
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
|
||||
if (existingKey && existingKey.name !== attr.attributeKey.name) {
|
||||
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both missing keys and name updates in a single batch operation
|
||||
const keysToUpsert = new Map<string, { key: string; name: string }>();
|
||||
|
||||
// Collect all keys that need to be created or updated
|
||||
for (const [key, value] of missingKeysMap) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
|
||||
for (const [key, value] of attributeKeyNameUpdates) {
|
||||
keysToUpsert.set(key, value);
|
||||
}
|
||||
// Build keys to upsert
|
||||
const keysToUpsert = buildKeysToUpsert(
|
||||
missingKeysMap,
|
||||
attributeKeyNameUpdates,
|
||||
attributeTypeMap,
|
||||
existingAttributeKeys
|
||||
);
|
||||
|
||||
// Upsert attribute keys in batches
|
||||
if (keysToUpsert.size > 0) {
|
||||
const keysArray = Array.from(keysToUpsert.values());
|
||||
const BATCH_SIZE = 10000;
|
||||
|
||||
for (let i = 0; i < keysArray.length; i += BATCH_SIZE) {
|
||||
const batch = keysArray.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use raw query to perform upsert
|
||||
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
|
||||
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
|
||||
${environmentId},
|
||||
NOW(),
|
||||
NOW()
|
||||
ON CONFLICT ("key", "environmentId")
|
||||
DO UPDATE SET
|
||||
"name" = EXCLUDED."name",
|
||||
"updated_at" = NOW()
|
||||
RETURNING "id", "key"
|
||||
`;
|
||||
|
||||
// Update attribute key map with upserted keys
|
||||
for (const key of upsertedKeys) {
|
||||
attributeKeyMap[key.key] = key.id;
|
||||
}
|
||||
}
|
||||
await upsertAttributeKeysInBatches(tx, keysToUpsert, environmentId, attributeKeyMap);
|
||||
}
|
||||
|
||||
// Create new contacts -- should be at most 1000, no need to batch
|
||||
const newContacts = contactsToCreate.map(() => ({
|
||||
id: createId(),
|
||||
environmentId,
|
||||
}));
|
||||
// Create new contacts
|
||||
const newContacts = contactsToCreate.map(() => ({ id: createId(), environmentId }));
|
||||
|
||||
if (newContacts.length > 0) {
|
||||
await tx.contact.createMany({
|
||||
data: newContacts,
|
||||
});
|
||||
await tx.contact.createMany({ data: newContacts });
|
||||
}
|
||||
|
||||
// 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(),
|
||||
}))
|
||||
const attributesForNewContacts = prepareAttributesForNewContacts(
|
||||
contactsToCreate,
|
||||
newContacts,
|
||||
attributeKeyMap,
|
||||
attributeTypeMap
|
||||
);
|
||||
|
||||
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(),
|
||||
}))
|
||||
const attributesForExistingContacts = prepareAttributesForExistingContacts(
|
||||
contactsToUpdate,
|
||||
attributeKeyMap,
|
||||
attributeTypeMap
|
||||
);
|
||||
|
||||
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
|
||||
const attributesToUpsert = [...attributesForNewContacts, ...attributesForExistingContacts];
|
||||
|
||||
// Skip the raw query if there are no attributes to upsert
|
||||
// Upsert attributes in batches
|
||||
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"
|
||||
)
|
||||
SELECT
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.id)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.createdAt)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.updatedAt)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.contactId)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.value)}]`}),
|
||||
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.attributeKeyId)}]`})
|
||||
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
|
||||
"value" = EXCLUDED."value",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`;
|
||||
}
|
||||
await upsertAttributesInBatches(tx, attributesToUpsert);
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 10 * 1000, // 10 seconds
|
||||
}
|
||||
{ timeout: 10 * 1000 }
|
||||
);
|
||||
|
||||
return ok({
|
||||
|
||||
@@ -92,7 +92,6 @@ describe("contact.ts", () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValueOnce({
|
||||
id: "existing-contact-id",
|
||||
environmentId: "env123",
|
||||
userId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
@@ -122,7 +121,6 @@ describe("contact.ts", () => {
|
||||
.mockResolvedValueOnce({
|
||||
id: "existing-contact-id",
|
||||
environmentId: "env123",
|
||||
userId: "user123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}); // Existing contact by userId
|
||||
@@ -160,12 +158,16 @@ describe("contact.ts", () => {
|
||||
userId: null,
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: existingAttributeKeys[0],
|
||||
attributeKey: { ...existingAttributeKeys[0], dataType: "string" },
|
||||
value: "john@example.com",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
{
|
||||
attributeKey: existingAttributeKeys[1],
|
||||
attributeKey: { ...existingAttributeKeys[1], dataType: "string" },
|
||||
value: "John",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -256,8 +258,10 @@ describe("contact.ts", () => {
|
||||
userId: null,
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: existingAttributeKeys[0],
|
||||
attributeKey: { ...existingAttributeKeys[0], dataType: "string" },
|
||||
value: "john@example.com",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -323,9 +327,24 @@ describe("contact.ts", () => {
|
||||
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
userId: null,
|
||||
attributes: [
|
||||
{ attributeKey: existingAttributeKeys[0], value: "john@example.com" },
|
||||
{ attributeKey: existingAttributeKeys[1], value: "user123" },
|
||||
{ attributeKey: existingAttributeKeys[2], value: "John" },
|
||||
{
|
||||
attributeKey: { ...existingAttributeKeys[0], dataType: "string" },
|
||||
value: "john@example.com",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
{
|
||||
attributeKey: { ...existingAttributeKeys[1], dataType: "string" },
|
||||
value: "user123",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
{
|
||||
attributeKey: { ...existingAttributeKeys[2], dataType: "string" },
|
||||
value: "John",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { readAttributeValue } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { TContactCreateRequest, TContactResponse } from "@/modules/ee/contacts/types/contact";
|
||||
|
||||
export const createContact = async (
|
||||
@@ -115,10 +116,10 @@ export const createContact = async (
|
||||
},
|
||||
});
|
||||
|
||||
// Format the response with flattened attributes
|
||||
// Format the response with flattened attributes, resolving from typed columns
|
||||
const flattenedAttributes: Record<string, string> = {};
|
||||
result.attributes.forEach((attr) => {
|
||||
flattenedAttributes[attr.attributeKey.key] = attr.value;
|
||||
flattenedAttributes[attr.attributeKey.key] = readAttributeValue(attr, attr.attributeKey.dataType);
|
||||
});
|
||||
|
||||
const response: TContactResponse = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -24,6 +25,7 @@ const ZCreateContactAttributeKeyAction = z.object({
|
||||
}),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
dataType: ZContactAttributeDataType.optional(),
|
||||
});
|
||||
|
||||
type TCreateContactAttributeKeyActionInput = z.infer<typeof ZCreateContactAttributeKeyAction>;
|
||||
@@ -66,6 +68,7 @@ export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
dataType: parsedInput.dataType,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { CalendarIcon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -63,6 +65,45 @@ export const generateAttributeTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "dataType",
|
||||
accessorKey: "dataType",
|
||||
header: t("environments.contacts.data_type"),
|
||||
cell: ({ row }) => {
|
||||
const dataType = row.original.dataType;
|
||||
const getIcon = () => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <CalendarIcon className="h-4 w-4" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return t("common.date");
|
||||
case "number":
|
||||
return t("common.number");
|
||||
case "string":
|
||||
default:
|
||||
return t("common.text");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">{getIcon()}</span>
|
||||
<Badge type="gray" size="normal" text={getLabel()} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
@@ -73,7 +114,14 @@ export const generateAttributeTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const baseColumns = [labelColumn, keyColumn, descriptionColumn, createdAtColumn, updatedAtColumn];
|
||||
const baseColumns = [
|
||||
createdAtColumn,
|
||||
labelColumn,
|
||||
keyColumn,
|
||||
descriptionColumn,
|
||||
dataTypeColumn,
|
||||
updatedAtColumn,
|
||||
];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn<TContactAttributeKey>(), ...baseColumns];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Calendar1Icon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { createContactAttributeKeyAction } from "../actions";
|
||||
|
||||
interface CreateAttributeModalProps {
|
||||
@@ -33,6 +41,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string" as TContactAttributeDataType,
|
||||
});
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
@@ -41,6 +50,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
dataType: "string",
|
||||
});
|
||||
setKeyError("");
|
||||
setOpen(false);
|
||||
@@ -54,7 +64,18 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
};
|
||||
|
||||
const handleKeyChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, key: value }));
|
||||
const previousAutoLabel = formData.key ? formatSnakeCaseToTitleCase(formData.key) : "";
|
||||
const newAutoLabel = value ? formatSnakeCaseToTitleCase(value) : "";
|
||||
|
||||
setFormData((prev) => {
|
||||
// Auto-update name if it's empty or matches the previous auto-generated label
|
||||
const shouldAutoUpdateName = !prev.name || prev.name === previousAutoLabel;
|
||||
return {
|
||||
...prev,
|
||||
key: value,
|
||||
name: shouldAutoUpdateName ? newAutoLabel : prev.name,
|
||||
};
|
||||
});
|
||||
validateKey(value);
|
||||
};
|
||||
|
||||
@@ -90,8 +111,9 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
const createContactAttributeKeyResponse = await createContactAttributeKeyAction({
|
||||
environmentId,
|
||||
key: formData.key,
|
||||
name: formData.name || formData.key,
|
||||
name: formData.name || formatSnakeCaseToTitleCase(formData.key),
|
||||
description: formData.description || undefined,
|
||||
dataType: formData.dataType,
|
||||
});
|
||||
|
||||
if (!createContactAttributeKeyResponse?.data) {
|
||||
@@ -166,6 +188,42 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<Select
|
||||
value={formData.dataType}
|
||||
onValueChange={(value: TContactAttributeDataType) =>
|
||||
setFormData((prev) => ({ ...prev, dataType: value }))
|
||||
}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<span>{t("common.string")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="number">
|
||||
<div className="flex items-center gap-2">
|
||||
<HashIcon className="h-4 w-4" />
|
||||
<span>{t("common.number")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="date">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar1Icon className="h-4 w-4" />
|
||||
<span>{t("common.date")}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">{t("environments.contacts.data_type_description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +21,18 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateContactAttributeKeyAction } from "../actions";
|
||||
|
||||
const getDataTypeIcon = (dataType: string) => {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return <Calendar1Icon className="h-4 w-4" />;
|
||||
case "number":
|
||||
return <HashIcon className="h-4 w-4" />;
|
||||
case "string":
|
||||
default:
|
||||
return <TagIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
interface EditAttributeModalProps {
|
||||
attribute: TContactAttributeKey;
|
||||
open: boolean;
|
||||
@@ -86,6 +100,19 @@ export function EditAttributeModal({ attribute, open, setOpen }: Readonly<EditAt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.data_type")}
|
||||
</label>
|
||||
<div className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3">
|
||||
{getDataTypeIcon(attribute.dataType)}
|
||||
<Badge text={t(`common.${attribute.dataType}`)} type="gray" size="tiny" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.data_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
|
||||
169
apps/web/modules/ee/contacts/components/attribute-field-row.tsx
Normal file
169
apps/web/modules/ee/contacts/components/attribute-field-row.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarIcon, HashIcon, TagIcon, TrashIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
type KeyOption = {
|
||||
icon: typeof CalendarIcon | typeof HashIcon | typeof TagIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface AttributeFieldRowProps {
|
||||
index: number;
|
||||
fieldId: string;
|
||||
form: any;
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
watchedAttributes: { key: string; value: string }[];
|
||||
allKeyOptions: KeyOption[];
|
||||
getAvailableOptions: (index: number) => KeyOption[];
|
||||
savedAttributeKeys: Set<string>;
|
||||
onRemove: (index: number) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const AttributeFieldRow = ({
|
||||
index,
|
||||
fieldId,
|
||||
form,
|
||||
attributeKeys,
|
||||
watchedAttributes,
|
||||
allKeyOptions,
|
||||
getAvailableOptions,
|
||||
savedAttributeKeys,
|
||||
onRemove,
|
||||
t,
|
||||
}: AttributeFieldRowProps) => {
|
||||
const availableOptions = getAvailableOptions(index);
|
||||
|
||||
return (
|
||||
<div key={fieldId} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.key`}
|
||||
render={({ field: keyField }) => {
|
||||
const selectedOption = allKeyOptions.find((opt) => opt.value === keyField.value);
|
||||
const Icon = selectedOption?.icon ?? TagIcon;
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t("environments.contacts.attribute_key")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={keyField.value || undefined}
|
||||
onValueChange={(value) => keyField.onChange(value)}
|
||||
disabled={savedAttributeKeys.has(keyField.value)}>
|
||||
<SelectTrigger id={`attribute-key-${index}`} className="h-10 w-full">
|
||||
{keyField.value ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
<span>{selectedOption?.label ?? keyField.value}</span>
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder={t("environments.contacts.select_attribute_key")} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOptions.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
return (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<OptionIcon className="h-4 w-4 text-slate-400" />
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.value`}
|
||||
render={({ field: valueField }) => {
|
||||
const selectedKey = attributeKeys.find((ak) => ak.key === watchedAttributes[index]?.key);
|
||||
const dataType = selectedKey?.dataType || "string";
|
||||
|
||||
const renderValueInput = () => {
|
||||
if (dataType === "date") {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={valueField.value ? valueField.value.split("T")[0] : ""}
|
||||
onChange={(e) => {
|
||||
const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
|
||||
valueField.onChange(dateValue);
|
||||
}}
|
||||
placeholder={t("environments.contacts.attribute_value_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (dataType === "number") {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
{...valueField}
|
||||
placeholder={t("environments.contacts.attribute_value_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
{...valueField}
|
||||
placeholder={t("environments.contacts.attribute_value_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t("environments.contacts.attribute_value")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex space-x-2">
|
||||
{renderValueInput()}
|
||||
<div className="flex items-end pb-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={["email", "userId", "firstName", "lastName"].includes(
|
||||
watchedAttributes[index]?.key ?? ""
|
||||
)}
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="h-10 w-10 p-0">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { debounce } from "lodash";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { getContactsAction } from "../actions";
|
||||
import { TContactTableData, TContactWithAttributes } from "../types/contact";
|
||||
|
||||
const ContactsTableDynamic = dynamic(() => import("./contacts-table").then((mod) => mod.ContactsTable), {
|
||||
loading: () => <LoadingSpinner />,
|
||||
ssr: false,
|
||||
});
|
||||
import { ContactsTable } from "./contacts-table";
|
||||
|
||||
interface ContactDataViewProps {
|
||||
environment: TEnvironment;
|
||||
@@ -38,9 +32,37 @@ export const ContactDataView = ({
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialHasMore);
|
||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(true);
|
||||
|
||||
const isFirstRender = useRef(true);
|
||||
const prevEnvironmentId = useRef(environment.id);
|
||||
const isResettingSearch = useRef(false);
|
||||
const prevInitialContactsLength = useRef(initialContacts.length);
|
||||
|
||||
// Sync state with server data only when environment changes (real tab navigation)
|
||||
useEffect(() => {
|
||||
if (prevEnvironmentId.current !== environment.id) {
|
||||
prevEnvironmentId.current = environment.id;
|
||||
setContacts([...initialContacts]);
|
||||
setHasMore(initialHasMore);
|
||||
isResettingSearch.current = true;
|
||||
setSearchValue("");
|
||||
prevInitialContactsLength.current = initialContacts.length;
|
||||
}
|
||||
}, [environment.id, initialContacts, initialHasMore]);
|
||||
|
||||
// Sync state when initialContacts changes from server refresh (e.g., after CSV upload)
|
||||
// Only update if we're viewing the first page without search
|
||||
useEffect(() => {
|
||||
if (
|
||||
!searchValue &&
|
||||
initialContacts.length !== prevInitialContactsLength.current &&
|
||||
prevEnvironmentId.current === environment.id
|
||||
) {
|
||||
setContacts([...initialContacts]);
|
||||
setHasMore(initialHasMore);
|
||||
prevInitialContactsLength.current = initialContacts.length;
|
||||
}
|
||||
}, [initialContacts, initialHasMore, searchValue, environment.id]);
|
||||
|
||||
const environmentAttributes = useMemo(() => {
|
||||
return contactAttributeKeys.filter(
|
||||
@@ -50,9 +72,8 @@ export const ContactDataView = ({
|
||||
|
||||
// Fetch contacts from offset 0 with current search value
|
||||
const fetchContactsFromStart = useCallback(async () => {
|
||||
setIsDataLoaded(false);
|
||||
// Don't show loading state - fetch in background
|
||||
try {
|
||||
setHasMore(true);
|
||||
const contactsResponse = await getContactsAction({
|
||||
environmentId: environment.id,
|
||||
offset: 0,
|
||||
@@ -60,20 +81,19 @@ export const ContactDataView = ({
|
||||
});
|
||||
if (contactsResponse?.data) {
|
||||
setContacts(contactsResponse.data);
|
||||
}
|
||||
if (contactsResponse?.data && contactsResponse.data.length < itemsPerPage) {
|
||||
setHasMore(false);
|
||||
// Only update hasMore based on actual response
|
||||
setHasMore(contactsResponse.data.length >= itemsPerPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
toast.error("Error fetching contacts. Please try again.");
|
||||
} finally {
|
||||
setIsDataLoaded(true);
|
||||
}
|
||||
}, [environment.id, itemsPerPage, searchValue]);
|
||||
|
||||
// Only refetch when search value actually changes (debounced)
|
||||
useEffect(() => {
|
||||
if (!isFirstRender.current) {
|
||||
// Don't trigger search on first render or when resetting after tab navigation
|
||||
if (!isFirstRender.current && !isResettingSearch.current) {
|
||||
const debouncedFetchData = debounce(fetchContactsFromStart, 300);
|
||||
debouncedFetchData();
|
||||
|
||||
@@ -81,7 +101,13 @@ export const ContactDataView = ({
|
||||
debouncedFetchData.cancel();
|
||||
};
|
||||
}
|
||||
}, [fetchContactsFromStart]);
|
||||
|
||||
// Reset the flag after search reset completes
|
||||
if (isResettingSearch.current) {
|
||||
isResettingSearch.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
@@ -131,16 +157,17 @@ export const ContactDataView = ({
|
||||
key: attr.key,
|
||||
name: attr.name,
|
||||
value: contact.attributes[attr.key] ?? "",
|
||||
dataType: attr.dataType,
|
||||
})),
|
||||
}));
|
||||
}, [contacts, environmentAttributes]);
|
||||
|
||||
return (
|
||||
<ContactsTableDynamic
|
||||
<ContactsTable
|
||||
data={contactsTableData}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasMore={hasMore}
|
||||
isDataLoaded={isFirstRender.current ? true : isDataLoaded}
|
||||
isDataLoaded={true}
|
||||
updateContactList={updateContactList}
|
||||
environmentId={environment.id}
|
||||
searchValue={searchValue}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -27,7 +28,7 @@ export const generateContactTableColumns = (
|
||||
header: "User ID",
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.userId;
|
||||
return <IdBadge id={userId} showCopyIconOnHover={true} />;
|
||||
return <IdBadge id={userId} />;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +72,9 @@ export const generateContactTableColumns = (
|
||||
header: attr.name ?? attr.key,
|
||||
cell: ({ row }) => {
|
||||
const attribute = row.original.attributes.find((a) => a.key === attr.key);
|
||||
return <HighlightedText value={attribute?.value} searchValue={searchValue} />;
|
||||
if (!attribute) return null;
|
||||
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
|
||||
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -294,9 +294,9 @@ export const ContactsTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("common.no_results")}
|
||||
<p className="text-slate-400">{t("common.no_results")}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { CalendarIcon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -20,24 +19,23 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-combo-box";
|
||||
import { FormError, FormProvider } from "@/modules/ui/components/form";
|
||||
import { updateContactAttributesAction } from "../actions";
|
||||
import { TEditContactAttributesForm, ZEditContactAttributesForm } from "../types/contact";
|
||||
import { TEditContactAttributesForm, createEditContactAttributesSchema } from "../types/contact";
|
||||
import { AttributeFieldRow } from "./attribute-field-row";
|
||||
|
||||
interface TContactAttributeWithKeyInfo {
|
||||
key: string;
|
||||
name: string | null;
|
||||
value: string;
|
||||
dataType: TContactAttributeDataType;
|
||||
}
|
||||
|
||||
interface EditContactAttributesModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactId: string;
|
||||
currentAttributes: TContactAttributes;
|
||||
currentAttributes: TContactAttributeWithKeyInfo[];
|
||||
attributeKeys: TContactAttributeKey[];
|
||||
}
|
||||
|
||||
@@ -50,19 +48,25 @@ export const EditContactAttributesModal = ({
|
||||
}: EditContactAttributesModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
// Create dynamic schema with type validation using factory function
|
||||
const dynamicSchema = useMemo(() => {
|
||||
return createEditContactAttributesSchema(attributeKeys, t);
|
||||
}, [attributeKeys, t]);
|
||||
|
||||
// Convert current attributes to form format
|
||||
const defaultValues: TEditContactAttributesForm = useMemo(
|
||||
() => ({
|
||||
attributes: Object.entries(currentAttributes).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
attributes: currentAttributes.map((attr) => ({
|
||||
key: attr.key,
|
||||
value: attr.value ?? "",
|
||||
})),
|
||||
}),
|
||||
[currentAttributes]
|
||||
);
|
||||
|
||||
const form = useForm<TEditContactAttributesForm>({
|
||||
resolver: zodResolver(ZEditContactAttributesForm),
|
||||
resolver: zodResolver(dynamicSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
@@ -74,21 +78,35 @@ export const EditContactAttributesModal = ({
|
||||
// Watch form values to get currently selected keys
|
||||
const watchedAttributes = form.watch("attributes");
|
||||
|
||||
// Prepare combobox options from attribute keys
|
||||
const allKeyOptions: TComboboxOption[] = attributeKeys.map((attrKey) => ({
|
||||
// Track which attributes were already saved (should be disabled)
|
||||
const savedAttributeKeys = useMemo(
|
||||
() => new Set(currentAttributes.map((attr) => attr.key)),
|
||||
[currentAttributes]
|
||||
);
|
||||
|
||||
// Icon mapping for attribute data types
|
||||
const dataTypeIcons = {
|
||||
date: CalendarIcon,
|
||||
number: HashIcon,
|
||||
string: TagIcon,
|
||||
} as const;
|
||||
|
||||
// Prepare select options from attribute keys
|
||||
const allKeyOptions = attributeKeys.map((attrKey) => ({
|
||||
icon: dataTypeIcons[attrKey.dataType] ?? TagIcon,
|
||||
label: attrKey.name ?? attrKey.key,
|
||||
value: attrKey.key,
|
||||
}));
|
||||
|
||||
// Get available options for a specific field index (exclude already selected keys from other fields)
|
||||
const getAvailableOptions = (currentIndex: number): TComboboxOption[] => {
|
||||
const getAvailableOptions = (currentIndex: number) => {
|
||||
const selectedKeys = new Set(
|
||||
watchedAttributes
|
||||
.map((attr, index) => (index !== currentIndex && attr.key ? String(attr.key) : null))
|
||||
.filter((key): key is string => key !== null && key !== "")
|
||||
);
|
||||
|
||||
return allKeyOptions.filter((option) => !selectedKeys.has(String(option.value)));
|
||||
return allKeyOptions.filter((option) => !selectedKeys.has(option.value));
|
||||
};
|
||||
|
||||
// Reset form when modal closes
|
||||
@@ -132,10 +150,23 @@ export const EditContactAttributesModal = ({
|
||||
|
||||
const onSubmit = async (data: TEditContactAttributesForm) => {
|
||||
try {
|
||||
const attributes = data.attributes.reduce((acc, { key, value }) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
// Convert values based on attribute data type
|
||||
// HTML inputs always return strings, so we need to convert numbers
|
||||
const attributes = data.attributes.reduce(
|
||||
(acc, { key, value }) => {
|
||||
const attrKey = attributeKeys.find((ak) => ak.key === key);
|
||||
const dataType = attrKey?.dataType || "string";
|
||||
|
||||
if (dataType === "number" && value !== "") {
|
||||
// Convert string to number for number attributes
|
||||
acc[key] = Number(value);
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
const result = await updateContactAttributesAction({
|
||||
contactId,
|
||||
@@ -146,10 +177,31 @@ export const EditContactAttributesModal = ({
|
||||
toast.success(t("environments.contacts.edit_attributes_success"));
|
||||
|
||||
if (result.data.messages && result.data.messages.length > 0) {
|
||||
result.data.messages.forEach((message) => {
|
||||
toast.error(message, { duration: 5000 });
|
||||
const translateMessage = (code: string, params: Record<string, string>): string => {
|
||||
switch (code) {
|
||||
case "email_or_userid_required":
|
||||
return t("environments.contacts.attributes_msg_email_or_userid_required");
|
||||
case "attribute_type_validation_error":
|
||||
return t("environments.contacts.attributes_msg_attribute_type_validation_error", params);
|
||||
case "email_already_exists":
|
||||
return t("environments.contacts.attributes_msg_email_already_exists");
|
||||
case "userid_already_exists":
|
||||
return t("environments.contacts.attributes_msg_userid_already_exists");
|
||||
case "attribute_limit_exceeded":
|
||||
return t("environments.contacts.attributes_msg_attribute_limit_exceeded", params);
|
||||
case "new_attribute_created":
|
||||
return t("environments.contacts.attributes_msg_new_attribute_created", params);
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
result.data.messages.forEach((msg) => {
|
||||
const errorMessage = translateMessage(msg.code, msg.params);
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
|
||||
setOpen(false);
|
||||
@@ -188,74 +240,33 @@ export const EditContactAttributesModal = ({
|
||||
<DialogBody>
|
||||
<FormProvider {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.key`}
|
||||
render={({ field: keyField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t("environments.contacts.attribute_key")}</FormLabel>
|
||||
<FormControl>
|
||||
<InputCombobox
|
||||
id={`attribute-key-${index}`}
|
||||
options={getAvailableOptions(index)}
|
||||
value={keyField.value || null}
|
||||
onChangeValue={(value) => {
|
||||
keyField.onChange(typeof value === "string" ? value : String(value || ""));
|
||||
}}
|
||||
withInput={true}
|
||||
showSearch={true}
|
||||
inputProps={{
|
||||
placeholder: t("environments.contacts.attribute_key_placeholder"),
|
||||
className: "w-full border-0",
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<AttributeFieldRow
|
||||
key={field.id}
|
||||
index={index}
|
||||
fieldId={field.id}
|
||||
form={form}
|
||||
attributeKeys={attributeKeys}
|
||||
watchedAttributes={watchedAttributes}
|
||||
allKeyOptions={allKeyOptions}
|
||||
getAvailableOptions={getAvailableOptions}
|
||||
savedAttributeKeys={savedAttributeKeys}
|
||||
onRemove={handleRemoveAttribute}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.value`}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t("environments.contacts.attribute_value")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
{...valueField}
|
||||
placeholder={t("environments.contacts.attribute_value_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-end pb-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)}
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAttribute(index)}
|
||||
className="h-10 w-10 p-0">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="secondary" onClick={handleAddAttribute} className="w-fit">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.contacts.add_attribute")}
|
||||
</Button>
|
||||
{/* Only show Add Attribute button if there are remaining attributes to add */}
|
||||
{watchedAttributes.length < attributeKeys.length && (
|
||||
<Button type="button" variant="secondary" onClick={handleAddAttribute} className="w-fit">
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.contacts.add_attribute")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{form.formState.errors.attributes?.root && (
|
||||
<FormError>{form.formState.errors.attributes.root.message}</FormError>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -47,6 +48,22 @@ export const UploadContactsAttributeCombobox = ({
|
||||
}
|
||||
}, [open, setSearchValue]);
|
||||
|
||||
// Check if the search value is a valid safe identifier for creating new attributes
|
||||
const isValidNewKey = useMemo(() => {
|
||||
if (!searchValue) return false;
|
||||
return isSafeIdentifier(searchValue.trim());
|
||||
}, [searchValue]);
|
||||
|
||||
const existingKeyMatch = useMemo(() => {
|
||||
return keys.find((tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase()));
|
||||
}, [keys, searchValue]);
|
||||
|
||||
const handleCreateKey = () => {
|
||||
if (isValidNewKey && !existingKeyMatch) {
|
||||
createKey(searchValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -96,9 +113,7 @@ export const UploadContactsAttributeCombobox = ({
|
||||
onValueChange={(search) => setSearchValue(search)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && searchValue !== "") {
|
||||
if (!keys.find((tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase()))) {
|
||||
createKey(searchValue);
|
||||
}
|
||||
handleCreateKey();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -119,18 +134,24 @@ export const UploadContactsAttributeCombobox = ({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{searchValue !== "" &&
|
||||
!keys.find((tag) => tag.label === searchValue) &&
|
||||
!keys.find((tag) => tag.label === searchValue) && (
|
||||
<CommandItem value="_create">
|
||||
{searchValue !== "" && !keys.some((tag) => tag.label === searchValue) && (
|
||||
<CommandItem value="_create">
|
||||
{isValidNewKey ? (
|
||||
<button
|
||||
onClick={() => createKey(searchValue)}
|
||||
onClick={handleCreateKey}
|
||||
className="h-8 w-full text-left hover:cursor-pointer hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!!keys.find((tag) => tag.label === searchValue)}>
|
||||
disabled={!!existingKeyMatch}>
|
||||
+ Add {searchValue}
|
||||
</button>
|
||||
</CommandItem>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col py-1 text-xs text-slate-500">
|
||||
<span className="text-red-500">
|
||||
{t("environments.contacts.attribute_key_safe_identifier_required")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { UploadContactsAttributeCombobox } from "@/modules/ee/contacts/components/upload-contacts-attribute-combobox";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
@@ -122,17 +123,17 @@ export const UploadContactsAttributes = ({
|
||||
useEffect(() => {
|
||||
if (currentKey) {
|
||||
const _isNewTag = contactAttributeKeys.findIndex((attrKey) => attrKey.id === currentKey.value) === -1;
|
||||
setIsNewTag(_isNewTag);
|
||||
// Only mark as new tag if it's a valid safe identifier (can actually be created)
|
||||
const mappedValue = attributeMap[csvColumn];
|
||||
const isValidNewKey = _isNewTag && !!mappedValue && isSafeIdentifier(mappedValue);
|
||||
setIsNewTag(isValidNewKey);
|
||||
}
|
||||
}, [contactAttributeKeys, currentKey]);
|
||||
}, [contactAttributeKeys, currentKey, attributeMap, csvColumn]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-start gap-4">
|
||||
<span className="w-25 overflow-hidden text-ellipsis font-medium text-slate-700">{csvColumn}</span>
|
||||
<h4 className="text-sm font-medium text-slate-500">
|
||||
{t("environments.contacts.upload_contacts_modal_attributes_should_be_mapped_to")}
|
||||
</h4>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<>
|
||||
<span className="overflow-hidden font-medium text-ellipsis text-slate-700">{csvColumn}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<UploadContactsAttributeCombobox
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
@@ -153,6 +154,6 @@ export const UploadContactsAttributes = ({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isStringMatch } from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
|
||||
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
|
||||
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
|
||||
@@ -53,19 +54,19 @@ export const UploadContactsCSVButton = ({
|
||||
|
||||
// Check file type
|
||||
if (!file.type && !file.name.endsWith(".csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
setError(t("environments.contacts.upload_contacts_error_invalid_file_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
|
||||
setError("Please upload a CSV file");
|
||||
setError(t("environments.contacts.upload_contacts_error_invalid_file_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Max file size check (800KB)
|
||||
const maxSizeInBytes = 800 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
setError("File size exceeds the maximum limit of 800KB");
|
||||
setError(t("environments.contacts.upload_contacts_error_file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,9 +89,7 @@ export const UploadContactsCSVButton = ({
|
||||
}
|
||||
|
||||
if (!parsedRecords.data.length) {
|
||||
setError(
|
||||
"The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format."
|
||||
);
|
||||
setError(t("environments.contacts.upload_contacts_error_no_valid_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,6 +120,18 @@ export const UploadContactsCSVButton = ({
|
||||
return headers.map((header) => header.trim());
|
||||
}, [csvResponse]);
|
||||
|
||||
// Filter columns to only show those that can be mapped (existing attributes or valid new keys)
|
||||
const validCsvColumns = useMemo(() => {
|
||||
return csvColumns.filter((column) => {
|
||||
// Check if column matches an existing attribute
|
||||
const matchesExisting = contactAttributeKeys.some((attrKey) =>
|
||||
isStringMatch(column, attrKey.name ?? attrKey.key)
|
||||
);
|
||||
// If it matches existing or is a valid safe identifier, include it
|
||||
return matchesExisting || isSafeIdentifier(column);
|
||||
});
|
||||
}, [csvColumns, contactAttributeKeys]);
|
||||
|
||||
const resetState = (closeModal?: boolean) => {
|
||||
setCSVResponse([]);
|
||||
setDuplicateContactsAction("skip");
|
||||
@@ -157,7 +168,11 @@ export const UploadContactsCSVButton = ({
|
||||
.filter(([_, value]) => duplicateValues.includes(value))
|
||||
.map(([key, _]) => key);
|
||||
|
||||
setError(`Duplicate mappings found for the following attributes: ${duplicateAttributeKeys.join(", ")}`);
|
||||
setError(
|
||||
t("environments.contacts.upload_contacts_error_duplicate_mappings", {
|
||||
attributes: duplicateAttributeKeys.join(", "),
|
||||
})
|
||||
);
|
||||
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -192,6 +207,23 @@ export const UploadContactsCSVButton = ({
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
if ("validationErrors" in result.data) {
|
||||
const { validationErrors } = result.data;
|
||||
const errorMessages = validationErrors.map((err) => {
|
||||
const sampleInvalid = err.invalidValues.slice(0, 3).join(", ");
|
||||
const additionalCount = err.invalidValues.length - 3;
|
||||
const suffix = additionalCount > 0 ? ` (${additionalCount.toString()} more)` : "";
|
||||
return t("environments.contacts.upload_contacts_error_attribute_type_mismatch", {
|
||||
key: err.key,
|
||||
dataType: err.dataType,
|
||||
values: `${sampleInvalid}${suffix}`,
|
||||
});
|
||||
});
|
||||
setError(errorMessages.join("\n"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
toast.success(t("environments.contacts.upload_contacts_success"));
|
||||
resetState(true);
|
||||
@@ -202,6 +234,8 @@ export const UploadContactsCSVButton = ({
|
||||
|
||||
if (result?.serverError) {
|
||||
setError(result.serverError);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.validationErrors) {
|
||||
@@ -212,8 +246,10 @@ export const UploadContactsCSVButton = ({
|
||||
if (csvDataErrors) {
|
||||
setError(csvDataErrors);
|
||||
} else {
|
||||
setError("An error occurred while uploading the contacts. Please try again later.");
|
||||
setError(t("environments.contacts.upload_contacts_error_generic"));
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -221,36 +257,96 @@ export const UploadContactsCSVButton = ({
|
||||
|
||||
useEffect(() => {
|
||||
const matches: Record<string, string> = {};
|
||||
const invalidColumns: string[] = [];
|
||||
|
||||
for (const columnName of csvColumns) {
|
||||
let matched = false;
|
||||
for (const attributeKey of contactAttributeKeys) {
|
||||
if (isStringMatch(columnName, attributeKey.name ?? attributeKey.key)) {
|
||||
matches[columnName] = attributeKey.id;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matches[columnName]) {
|
||||
if (!matched) {
|
||||
// This column will become a new attribute - validate it's a safe identifier
|
||||
if (!isSafeIdentifier(columnName)) {
|
||||
invalidColumns.push(columnName);
|
||||
}
|
||||
matches[columnName] = columnName;
|
||||
}
|
||||
}
|
||||
|
||||
setAttributeMap(matches);
|
||||
}, [contactAttributeKeys, csvColumns]);
|
||||
|
||||
// Show error for invalid column names that would become new attributes
|
||||
if (invalidColumns.length > 0) {
|
||||
setError(
|
||||
t("environments.contacts.invalid_csv_column_names", {
|
||||
columns: invalidColumns.join(", "),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [contactAttributeKeys, csvColumns, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && errorContainerRef.current) {
|
||||
errorContainerRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Small delay to ensure DOM has updated and the alert is visible
|
||||
setTimeout(() => {
|
||||
errorContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Function to download an example CSV
|
||||
const handleDownloadExampleCSV = () => {
|
||||
const exampleData = [
|
||||
{ email: "user1@example.com", userId: "1001", firstName: "John", lastName: "Doe" },
|
||||
{ email: "user2@example.com", userId: "1002", firstName: "Jane", lastName: "Smith" },
|
||||
{ email: "user3@example.com", userId: "1003", firstName: "Mark", lastName: "Jones" },
|
||||
{ email: "user4@example.com", userId: "1004", firstName: "Emily", lastName: "Brown" },
|
||||
{ email: "user5@example.com", userId: "1005", firstName: "David", lastName: "Wilson" },
|
||||
{
|
||||
email: "user1@example.com",
|
||||
userId: "1001",
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
age: "28",
|
||||
plan: "premium",
|
||||
signup_date: "2024-01-15",
|
||||
},
|
||||
{
|
||||
email: "user2@example.com",
|
||||
userId: "1002",
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
age: "34",
|
||||
plan: "free",
|
||||
signup_date: "2024-02-20",
|
||||
},
|
||||
{
|
||||
email: "user3@example.com",
|
||||
userId: "1003",
|
||||
first_name: "Mark",
|
||||
last_name: "Jones",
|
||||
age: "45",
|
||||
plan: "enterprise",
|
||||
signup_date: "2023-11-08",
|
||||
},
|
||||
{
|
||||
email: "user4@example.com",
|
||||
userId: "1004",
|
||||
first_name: "Emily",
|
||||
last_name: "Brown",
|
||||
age: "22",
|
||||
plan: "premium",
|
||||
signup_date: "2024-03-01",
|
||||
},
|
||||
{
|
||||
email: "user5@example.com",
|
||||
userId: "1005",
|
||||
first_name: "David",
|
||||
last_name: "Wilson",
|
||||
age: "31",
|
||||
plan: "free",
|
||||
signup_date: "2024-01-28",
|
||||
},
|
||||
];
|
||||
|
||||
const headers = Object.keys(exampleData[0]);
|
||||
@@ -318,9 +414,11 @@ export const UploadContactsCSVButton = ({
|
||||
<DialogBody unconstrained={false}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<Alert variant="error" size="small">
|
||||
{error}
|
||||
</Alert>
|
||||
<div ref={errorContainerRef}>
|
||||
<Alert variant="error" size="small">
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="no-scrollbar rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
|
||||
@@ -378,8 +476,15 @@ export const UploadContactsCSVButton = ({
|
||||
{t("environments.contacts.upload_contacts_modal_attributes_description")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{csvColumns.map((column, index) => {
|
||||
<div className="grid grid-cols-[minmax(150px,1fr)_minmax(200px,2fr)] gap-x-4 gap-y-3">
|
||||
<div className="font-medium text-slate-900">
|
||||
{t("environments.contacts.upload_contacts_modal_csv_column_header")}
|
||||
</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{t("environments.contacts.upload_contacts_modal_attribute_header")}
|
||||
</div>
|
||||
|
||||
{validCsvColumns.map((column, index) => {
|
||||
return (
|
||||
<UploadContactsAttributes
|
||||
key={index}
|
||||
@@ -448,7 +553,10 @@ export const UploadContactsCSVButton = ({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button onClick={handleUpload} loading={loading} disabled={loading || !csvResponse.length}>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
loading={loading}
|
||||
disabled={loading || !csvResponse.length || !!error}>
|
||||
{t("environments.contacts.upload_contacts_modal_upload_btn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
478
apps/web/modules/ee/contacts/lib/attribute-storage.test.ts
Normal file
478
apps/web/modules/ee/contacts/lib/attribute-storage.test.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
TAttributeStorageColumns,
|
||||
prepareAttributeColumnsForStorage,
|
||||
prepareNewAttributeForStorage,
|
||||
readAttributeValue,
|
||||
} from "./attribute-storage";
|
||||
|
||||
describe("attribute-storage", () => {
|
||||
describe("prepareNewAttributeForStorage", () => {
|
||||
describe("string values", () => {
|
||||
test("should detect plain string and prepare columns", () => {
|
||||
const result = prepareNewAttributeForStorage("hello world");
|
||||
|
||||
expect(result.dataType).toBe("string");
|
||||
expect(result.columns).toEqual({
|
||||
value: "hello world",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should detect email as string", () => {
|
||||
const result = prepareNewAttributeForStorage("test@example.com");
|
||||
|
||||
expect(result.dataType).toBe("string");
|
||||
expect(result.columns.value).toBe("test@example.com");
|
||||
expect(result.columns.valueNumber).toBeNull();
|
||||
expect(result.columns.valueDate).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
const result = prepareNewAttributeForStorage("");
|
||||
|
||||
expect(result.dataType).toBe("string");
|
||||
expect(result.columns.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("number values", () => {
|
||||
test("should detect integer and prepare columns", () => {
|
||||
const result = prepareNewAttributeForStorage(42);
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns).toEqual({
|
||||
value: "42",
|
||||
valueNumber: 42,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should detect float and prepare columns", () => {
|
||||
const result = prepareNewAttributeForStorage(3.14159);
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns).toEqual({
|
||||
value: "3.14159",
|
||||
valueNumber: 3.14159,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should detect negative number", () => {
|
||||
const result = prepareNewAttributeForStorage(-100);
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns.value).toBe("-100");
|
||||
expect(result.columns.valueNumber).toBe(-100);
|
||||
});
|
||||
|
||||
test("should detect zero", () => {
|
||||
const result = prepareNewAttributeForStorage(0);
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns.value).toBe("0");
|
||||
expect(result.columns.valueNumber).toBe(0);
|
||||
});
|
||||
|
||||
test("should detect numeric string and prepare columns", () => {
|
||||
const result = prepareNewAttributeForStorage("123");
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns).toEqual({
|
||||
value: "123",
|
||||
valueNumber: 123,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should detect numeric string with decimals", () => {
|
||||
const result = prepareNewAttributeForStorage("99.99");
|
||||
|
||||
expect(result.dataType).toBe("number");
|
||||
expect(result.columns.valueNumber).toBe(99.99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("date values", () => {
|
||||
test("should detect Date object and prepare columns", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = prepareNewAttributeForStorage(date);
|
||||
|
||||
expect(result.dataType).toBe("date");
|
||||
expect(result.columns.value).toBe("2024-06-15T10:30:00.000Z");
|
||||
expect(result.columns.valueNumber).toBeNull();
|
||||
expect(result.columns.valueDate).toEqual(date);
|
||||
});
|
||||
|
||||
test("should detect ISO date string and prepare columns", () => {
|
||||
const result = prepareNewAttributeForStorage("2024-06-15");
|
||||
|
||||
expect(result.dataType).toBe("date");
|
||||
expect(result.columns.valueDate).toBeInstanceOf(Date);
|
||||
expect(result.columns.valueNumber).toBeNull();
|
||||
});
|
||||
|
||||
test("should detect date string with time", () => {
|
||||
const result = prepareNewAttributeForStorage("2024-06-15T14:30:00Z");
|
||||
|
||||
expect(result.dataType).toBe("date");
|
||||
expect(result.columns.valueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("should detect date string with slashes", () => {
|
||||
const result = prepareNewAttributeForStorage("2024/01/15");
|
||||
|
||||
expect(result.dataType).toBe("date");
|
||||
expect(result.columns.valueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareAttributeColumnsForStorage", () => {
|
||||
describe("string dataType", () => {
|
||||
test("should handle string input", () => {
|
||||
const result = prepareAttributeColumnsForStorage("hello", "string");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "hello",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should convert number to string", () => {
|
||||
const result = prepareAttributeColumnsForStorage(42, "string");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "42",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should convert Date to ISO string", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = prepareAttributeColumnsForStorage(date, "string");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "2024-06-15T10:30:00.000Z",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("number dataType", () => {
|
||||
test("should handle number input", () => {
|
||||
const result = prepareAttributeColumnsForStorage(42, "number");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "42",
|
||||
valueNumber: 42,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse numeric string", () => {
|
||||
const result = prepareAttributeColumnsForStorage("123.45", "number");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "123.45",
|
||||
valueNumber: 123.45,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle string with whitespace", () => {
|
||||
const result = prepareAttributeColumnsForStorage(" 100 ", "number");
|
||||
|
||||
expect(result.valueNumber).toBe(100);
|
||||
expect(result.value).toBe("100");
|
||||
});
|
||||
|
||||
test("should gracefully degrade invalid number string to string-only storage", () => {
|
||||
const result = prepareAttributeColumnsForStorage("not a number", "number");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "not a number",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle NaN gracefully", () => {
|
||||
const result = prepareAttributeColumnsForStorage(NaN, "number");
|
||||
|
||||
expect(result.valueNumber).toBeNull();
|
||||
});
|
||||
|
||||
test("should convert Date to timestamp", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = prepareAttributeColumnsForStorage(date, "number");
|
||||
|
||||
expect(result.valueNumber).toBe(date.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("date dataType", () => {
|
||||
test("should handle Date input", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const result = prepareAttributeColumnsForStorage(date, "date");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "2024-06-15T10:30:00.000Z",
|
||||
valueNumber: null,
|
||||
valueDate: date,
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse ISO date string", () => {
|
||||
const result = prepareAttributeColumnsForStorage("2024-06-15", "date");
|
||||
|
||||
expect(result.valueDate).toBeInstanceOf(Date);
|
||||
expect(result.valueDate?.getUTCFullYear()).toBe(2024);
|
||||
expect(result.valueDate?.getUTCMonth()).toBe(5); // June is 5 (0-indexed)
|
||||
expect(result.valueDate?.getUTCDate()).toBe(15);
|
||||
expect(result.value).toBe(result.valueDate?.toISOString());
|
||||
});
|
||||
|
||||
test("should parse date string with slashes", () => {
|
||||
const result = prepareAttributeColumnsForStorage("2024/01/20", "date");
|
||||
|
||||
expect(result.valueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("should parse timestamp number", () => {
|
||||
const timestamp = new Date("2024-06-15T10:30:00.000Z").getTime();
|
||||
const result = prepareAttributeColumnsForStorage(timestamp, "date");
|
||||
|
||||
expect(result.valueDate).toBeInstanceOf(Date);
|
||||
expect(result.valueDate?.getTime()).toBe(timestamp);
|
||||
});
|
||||
|
||||
test("should gracefully degrade invalid date string to string-only storage", () => {
|
||||
const result = prepareAttributeColumnsForStorage("not a date", "date");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "not a date",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle string with whitespace", () => {
|
||||
const result = prepareAttributeColumnsForStorage(" 2024-06-15 ", "date");
|
||||
|
||||
expect(result.valueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default/unknown dataType", () => {
|
||||
test("should fallback to string storage for unknown type", () => {
|
||||
const result = prepareAttributeColumnsForStorage("test", "unknown" as "string");
|
||||
|
||||
expect(result).toEqual({
|
||||
value: "test",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAttributeValue", () => {
|
||||
describe("string dataType", () => {
|
||||
test("should return value column", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "hello world",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "string");
|
||||
|
||||
expect(result).toBe("hello world");
|
||||
});
|
||||
|
||||
test("should return value even if other columns are populated", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "test",
|
||||
valueNumber: 123,
|
||||
valueDate: new Date(),
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "string");
|
||||
|
||||
expect(result).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("number dataType", () => {
|
||||
test("should return valueNumber as string when available", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "42",
|
||||
valueNumber: 42,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "number");
|
||||
|
||||
expect(result).toBe("42");
|
||||
});
|
||||
|
||||
test("should return value as fallback when valueNumber is null", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "not a number",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "number");
|
||||
|
||||
expect(result).toBe("not a number");
|
||||
});
|
||||
|
||||
test("should handle zero correctly", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "0",
|
||||
valueNumber: 0,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "number");
|
||||
|
||||
expect(result).toBe("0");
|
||||
});
|
||||
|
||||
test("should handle negative numbers", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "-50",
|
||||
valueNumber: -50,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "number");
|
||||
|
||||
expect(result).toBe("-50");
|
||||
});
|
||||
|
||||
test("should handle float precision", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "3.14159",
|
||||
valueNumber: 3.14159,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "number");
|
||||
|
||||
expect(result).toBe("3.14159");
|
||||
});
|
||||
});
|
||||
|
||||
describe("date dataType", () => {
|
||||
test("should return valueDate as ISO string when available", () => {
|
||||
const date = new Date("2024-06-15T10:30:00.000Z");
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "2024-06-15T10:30:00.000Z",
|
||||
valueNumber: null,
|
||||
valueDate: date,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "date");
|
||||
|
||||
expect(result).toBe("2024-06-15T10:30:00.000Z");
|
||||
});
|
||||
|
||||
test("should return value as fallback when valueDate is null", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "invalid date stored",
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "date");
|
||||
|
||||
expect(result).toBe("invalid date stored");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default/unknown dataType", () => {
|
||||
test("should return value column for unknown type", () => {
|
||||
const attribute: TAttributeStorageColumns = {
|
||||
value: "fallback",
|
||||
valueNumber: 123,
|
||||
valueDate: new Date(),
|
||||
};
|
||||
|
||||
const result = readAttributeValue(attribute, "unknown" as "string");
|
||||
|
||||
expect(result).toBe("fallback");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
test("round-trip: prepare and read string value", () => {
|
||||
const originalValue = "hello world";
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(originalValue);
|
||||
const readValue = readAttributeValue(columns, dataType);
|
||||
|
||||
expect(readValue).toBe(originalValue);
|
||||
});
|
||||
|
||||
test("round-trip: prepare and read number value", () => {
|
||||
const originalValue = 42.5;
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(originalValue);
|
||||
const readValue = readAttributeValue(columns, dataType);
|
||||
|
||||
expect(readValue).toBe("42.5");
|
||||
expect(Number(readValue)).toBe(originalValue);
|
||||
});
|
||||
|
||||
test("round-trip: prepare and read date value", () => {
|
||||
const originalDate = new Date("2024-06-15T10:30:00.000Z");
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(originalDate);
|
||||
const readValue = readAttributeValue(columns, dataType);
|
||||
|
||||
expect(readValue).toBe(originalDate.toISOString());
|
||||
expect(new Date(readValue).getTime()).toBe(originalDate.getTime());
|
||||
});
|
||||
|
||||
test("round-trip: prepare and read numeric string", () => {
|
||||
const originalValue = "123";
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(originalValue);
|
||||
const readValue = readAttributeValue(columns, dataType);
|
||||
|
||||
expect(dataType).toBe("number");
|
||||
expect(readValue).toBe("123");
|
||||
});
|
||||
|
||||
test("round-trip: prepare and read date string", () => {
|
||||
const originalValue = "2024-06-15";
|
||||
const { dataType, columns } = prepareNewAttributeForStorage(originalValue);
|
||||
const readValue = readAttributeValue(columns, dataType);
|
||||
|
||||
expect(dataType).toBe("date");
|
||||
expect(columns.valueDate).toBeInstanceOf(Date);
|
||||
expect(readValue).toBe(columns.valueDate?.toISOString());
|
||||
});
|
||||
|
||||
test("graceful degradation: invalid number string maintains original value", () => {
|
||||
const originalValue = "abc123";
|
||||
const columns = prepareAttributeColumnsForStorage(originalValue, "number");
|
||||
const readValue = readAttributeValue(columns, "number");
|
||||
|
||||
expect(readValue).toBe("abc123");
|
||||
});
|
||||
|
||||
test("graceful degradation: invalid date string maintains original value", () => {
|
||||
const originalValue = "not-a-date";
|
||||
const columns = prepareAttributeColumnsForStorage(originalValue, "date");
|
||||
const readValue = readAttributeValue(columns, "date");
|
||||
|
||||
expect(readValue).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
});
|
||||
210
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal file
210
apps/web/modules/ee/contacts/lib/attribute-storage.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { detectAttributeDataType, detectSDKAttributeDataType, tryParseDate } from "./detect-attribute-type";
|
||||
|
||||
type TRawValue = string | number | Date;
|
||||
|
||||
/**
|
||||
* Storage columns for a contact attribute value
|
||||
*/
|
||||
export type TAttributeStorageColumns = {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares an attribute value for storage by routing to the appropriate column(s).
|
||||
* Used when creating a new attribute from CSV upload - detects type flexibly.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @returns Object with dataType and column values for storage
|
||||
*/
|
||||
export const prepareNewAttributeForStorage = (
|
||||
value: TRawValue
|
||||
): {
|
||||
dataType: TContactAttributeDataType;
|
||||
columns: TAttributeStorageColumns;
|
||||
} => {
|
||||
const dataType = detectAttributeDataType(value);
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return { dataType, columns };
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares an attribute value for storage from SDK input.
|
||||
* Uses STRICT type detection:
|
||||
* - JS number → number type
|
||||
* - ISO 8601 string → date type
|
||||
* - Any other string → string type (even if it looks like a number!)
|
||||
*
|
||||
* @param value - The value from SDK (string or number)
|
||||
* @returns Object with dataType and column values for storage
|
||||
*/
|
||||
export const prepareNewSDKAttributeForStorage = (
|
||||
value: string | number
|
||||
): {
|
||||
dataType: TContactAttributeDataType;
|
||||
columns: TAttributeStorageColumns;
|
||||
} => {
|
||||
const dataType = detectSDKAttributeDataType(value);
|
||||
const columns = prepareAttributeColumnsForStorage(value, dataType);
|
||||
|
||||
return { dataType, columns };
|
||||
};
|
||||
|
||||
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
|
||||
// String type - only use value column
|
||||
let stringValue: string;
|
||||
|
||||
if (value instanceof Date) {
|
||||
stringValue = value.toISOString();
|
||||
} else if (typeof value === "number") {
|
||||
stringValue = String(value);
|
||||
} else {
|
||||
stringValue = value;
|
||||
}
|
||||
|
||||
return {
|
||||
value: stringValue,
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
};
|
||||
|
||||
const handleNumberType = (value: TRawValue): TAttributeStorageColumns => {
|
||||
let numericValue: number | null = null;
|
||||
|
||||
if (typeof value === "number") {
|
||||
numericValue = Number.isNaN(value) ? null : value;
|
||||
} else if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
numericValue = Number.isNaN(parsed) ? null : parsed;
|
||||
} else {
|
||||
// Date - shouldn't happen if validation passed, but handle gracefully
|
||||
numericValue = value.getTime();
|
||||
}
|
||||
|
||||
// If number parsing failed, store as string only (graceful degradation)
|
||||
if (numericValue === null) {
|
||||
return {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: String(numericValue),
|
||||
valueNumber: numericValue,
|
||||
valueDate: null,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDateType = (value: TRawValue): TAttributeStorageColumns => {
|
||||
// Date type - use both value (for backwards compat) and valueDate columns
|
||||
let dateValue: Date | null = null;
|
||||
|
||||
if (value instanceof Date) {
|
||||
dateValue = value;
|
||||
} else if (typeof value === "string") {
|
||||
const parsedDate = tryParseDate(value.trim());
|
||||
if (parsedDate && !Number.isNaN(parsedDate.getTime())) {
|
||||
dateValue = parsedDate;
|
||||
} else {
|
||||
// Try standard Date parsing as fallback
|
||||
const standardDate = new Date(value);
|
||||
if (!Number.isNaN(standardDate.getTime())) {
|
||||
dateValue = standardDate;
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "number") {
|
||||
dateValue = new Date(value);
|
||||
if (Number.isNaN(dateValue.getTime())) {
|
||||
dateValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If date parsing failed, store as string only (graceful degradation)
|
||||
if (!dateValue) {
|
||||
return {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: dateValue.toISOString(),
|
||||
valueNumber: null,
|
||||
valueDate: dateValue,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares attribute column values based on the data type.
|
||||
* Used when updating an existing attribute with a known data type.
|
||||
*
|
||||
* @param value - The raw value to store (string, number, or Date)
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns Object with column values for storage
|
||||
*/
|
||||
export const prepareAttributeColumnsForStorage = (
|
||||
value: TRawValue,
|
||||
dataType: TContactAttributeDataType
|
||||
): TAttributeStorageColumns => {
|
||||
switch (dataType) {
|
||||
case "string":
|
||||
return handleStringType(value);
|
||||
case "number":
|
||||
return handleNumberType(value);
|
||||
case "date":
|
||||
return handleDateType(value);
|
||||
default:
|
||||
return {
|
||||
value: String(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an attribute value from the appropriate column based on data type.
|
||||
*
|
||||
* @param attribute - The attribute with all column values
|
||||
* @param dataType - The data type of the attribute key
|
||||
* @returns The value from the appropriate column
|
||||
*/
|
||||
export const readAttributeValue = (
|
||||
attribute: {
|
||||
value: string;
|
||||
valueNumber: number | null;
|
||||
valueDate: Date | null;
|
||||
},
|
||||
dataType: TContactAttributeDataType
|
||||
): string => {
|
||||
// For now, always return from value column for backwards compatibility
|
||||
// The typed columns are primarily for query performance
|
||||
switch (dataType) {
|
||||
case "number":
|
||||
// Return from valueNumber if available, otherwise fallback to value
|
||||
if (attribute.valueNumber === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
|
||||
return String(attribute.valueNumber);
|
||||
|
||||
case "date":
|
||||
// Return from valueDate if available, otherwise fallback to value
|
||||
if (attribute.valueDate === null) {
|
||||
return attribute.value;
|
||||
}
|
||||
|
||||
return attribute.valueDate.toISOString();
|
||||
|
||||
case "string":
|
||||
default:
|
||||
return attribute.value;
|
||||
}
|
||||
};
|
||||
@@ -111,7 +111,7 @@ describe("updateAttributes", () => {
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
||||
});
|
||||
|
||||
test("skips updating userId if it already exists", async () => {
|
||||
@@ -140,7 +140,7 @@ describe("updateAttributes", () => {
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
|
||||
@@ -174,8 +174,8 @@ describe("updateAttributes", () => {
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContain("The userId already exists for this environment and was not updated.");
|
||||
expect(result.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
||||
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
||||
expect(result.ignoreEmailAttribute).toBe(true);
|
||||
expect(result.ignoreUserIdAttribute).toBe(true);
|
||||
});
|
||||
@@ -205,10 +205,15 @@ describe("updateAttributes", () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
||||
// Include email to satisfy the "at least one of email or userId" requirement
|
||||
const attributes = { name: "John", email: "john@example.com", newAttr: "val" };
|
||||
const attributes = { name: "John", email: "john@example.com", new_attr: "val" };
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
|
||||
expect(result.messages?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "attribute_limit_exceeded",
|
||||
params: expect.objectContaining({ count: "1" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns success with only email attribute", async () => {
|
||||
@@ -430,8 +435,6 @@ describe("updateAttributes", () => {
|
||||
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toContain(
|
||||
"Either email or userId is required. The existing values were preserved."
|
||||
);
|
||||
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,67 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributesInput, ZContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
|
||||
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { prepareNewSDKAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import {
|
||||
getContactAttributes,
|
||||
hasEmailAttribute,
|
||||
hasUserIdAttribute,
|
||||
} from "@/modules/ee/contacts/lib/contact-attributes";
|
||||
import {
|
||||
formatValidationError,
|
||||
validateAndParseAttributeValue,
|
||||
} from "@/modules/ee/contacts/lib/validate-attribute-type";
|
||||
|
||||
/**
|
||||
* Structured message with code and params for i18n support.
|
||||
* Used for both UI-facing messages (translated) and API/SDK responses (formatted to English).
|
||||
*/
|
||||
export interface TAttributeUpdateMessage {
|
||||
code: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* English templates for formatting structured messages to human-readable strings.
|
||||
* Used by SDK/API paths that return English responses.
|
||||
*/
|
||||
const MESSAGE_TEMPLATES: Record<string, string> = {
|
||||
email_or_userid_required: "Either email or userId is required. The existing values were preserved.",
|
||||
attribute_type_validation_error: "{error} (attribute '{key}' has dataType: {dataType})",
|
||||
email_already_exists: "The email already exists for this environment and was not updated.",
|
||||
userid_already_exists: "The userId already exists for this environment and was not updated.",
|
||||
invalid_attribute_keys:
|
||||
"Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
attribute_limit_exceeded:
|
||||
"Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
|
||||
new_attribute_created: "Created new attribute '{key}' with type '{dataType}'",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a structured message to a human-readable English string.
|
||||
* Used for API/SDK responses.
|
||||
*/
|
||||
export const formatAttributeMessage = (msg: TAttributeUpdateMessage): string => {
|
||||
let template = MESSAGE_TEMPLATES[msg.code] || msg.code;
|
||||
for (const [key, value] of Object.entries(msg.params)) {
|
||||
template = template.replaceAll(`{${key}}`, value);
|
||||
}
|
||||
return template;
|
||||
};
|
||||
|
||||
// Default/system attributes that should not be deleted even if missing from payload
|
||||
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
|
||||
|
||||
const deleteAttributes = async (
|
||||
contactId: string,
|
||||
currentAttributes: TContactAttributes,
|
||||
submittedAttributes: TContactAttributes,
|
||||
currentAttributes: TContactAttributesInput,
|
||||
submittedAttributes: TContactAttributesInput,
|
||||
contactAttributeKeys: TContactAttributeKey[]
|
||||
): Promise<{ success: boolean }> => {
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
@@ -65,11 +109,12 @@ export const updateAttributes = async (
|
||||
contactId: string,
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
contactAttributesParam: TContactAttributes,
|
||||
contactAttributesParam: TContactAttributesInput,
|
||||
deleteRemovedAttributes: boolean = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
messages?: string[];
|
||||
messages?: TAttributeUpdateMessage[];
|
||||
errors?: TAttributeUpdateMessage[];
|
||||
ignoreEmailAttribute?: boolean;
|
||||
ignoreUserIdAttribute?: boolean;
|
||||
}> => {
|
||||
@@ -77,24 +122,31 @@ export const updateAttributes = async (
|
||||
[contactId, ZId],
|
||||
[userId, ZString],
|
||||
[environmentId, ZId],
|
||||
[contactAttributesParam, ZContactAttributes]
|
||||
[contactAttributesParam, ZContactAttributesInput]
|
||||
);
|
||||
|
||||
let ignoreEmailAttribute = false;
|
||||
let ignoreUserIdAttribute = false;
|
||||
const messages: string[] = [];
|
||||
const messages: TAttributeUpdateMessage[] = [];
|
||||
const errors: TAttributeUpdateMessage[] = [];
|
||||
|
||||
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
|
||||
const emailValue =
|
||||
contactAttributesParam.email === null || contactAttributesParam.email === undefined
|
||||
? null
|
||||
: String(contactAttributesParam.email);
|
||||
const userIdValue =
|
||||
contactAttributesParam.userId === null || contactAttributesParam.userId === undefined
|
||||
? null
|
||||
: String(contactAttributesParam.userId);
|
||||
|
||||
// Fetch current attributes, contact attribute keys, and email/userId checks in parallel
|
||||
const [currentAttributes, contactAttributeKeys, existingEmailAttribute, existingUserIdAttribute] =
|
||||
await Promise.all([
|
||||
getContactAttributes(contactId),
|
||||
getContactAttributeKeys(environmentId),
|
||||
contactAttributesParam.email
|
||||
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
contactAttributesParam.userId
|
||||
? hasUserIdAttribute(contactAttributesParam.userId, environmentId, contactId)
|
||||
: Promise.resolve(null),
|
||||
emailValue ? hasEmailAttribute(emailValue, environmentId, contactId) : Promise.resolve(null),
|
||||
userIdValue ? hasUserIdAttribute(userIdValue, environmentId, contactId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Process email and userId existence early
|
||||
@@ -109,8 +161,8 @@ export const updateAttributes = async (
|
||||
const emailWasSubmitted = "email" in contactAttributesParam;
|
||||
const userIdWasSubmitted = "userId" in contactAttributesParam;
|
||||
|
||||
const submittedEmail = emailWasSubmitted ? contactAttributes.email?.trim() || "" : null;
|
||||
const submittedUserId = userIdWasSubmitted ? contactAttributes.userId?.trim() || "" : null;
|
||||
const submittedEmail = emailWasSubmitted ? emailValue?.trim() || "" : null;
|
||||
const submittedUserId = userIdWasSubmitted ? userIdValue?.trim() || "" : null;
|
||||
|
||||
const currentEmail = currentAttributes.email || "";
|
||||
const currentUserId = currentAttributes.userId || "";
|
||||
@@ -143,7 +195,7 @@ export const updateAttributes = async (
|
||||
if (currentUserId) {
|
||||
contactAttributes.userId = currentUserId;
|
||||
}
|
||||
messages.push("Either email or userId is required. The existing values were preserved.");
|
||||
messages.push({ code: "email_or_userid_required", params: {} });
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
@@ -168,35 +220,56 @@ export const updateAttributes = async (
|
||||
// Create lookup map for attribute keys
|
||||
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
|
||||
|
||||
// Separate existing and new attributes in a single pass
|
||||
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
if (attributeKey) {
|
||||
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
|
||||
// Separate existing and new attributes, validating types for existing attributes
|
||||
const existingAttributes: {
|
||||
key: string;
|
||||
attributeKeyId: string;
|
||||
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
|
||||
}[] = [];
|
||||
const newAttributes: { key: string; value: string | number }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(contactAttributes)) {
|
||||
const attributeKey = contactAttributeKeyMap.get(key);
|
||||
|
||||
if (attributeKey) {
|
||||
// Existing attribute - validate type and prepare columns
|
||||
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
|
||||
|
||||
if (validationResult.valid) {
|
||||
existingAttributes.push({
|
||||
key,
|
||||
attributeKeyId: attributeKey.id,
|
||||
columns: validationResult.parsedValue,
|
||||
});
|
||||
} else {
|
||||
acc.newAttributes.push({ key, value });
|
||||
// Type mismatch - add structured error
|
||||
messages.push({
|
||||
code: "attribute_type_validation_error",
|
||||
params: {
|
||||
key,
|
||||
dataType: attributeKey.dataType,
|
||||
error: formatValidationError(validationResult.error),
|
||||
},
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ existingAttributes: [], newAttributes: [] } as {
|
||||
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
|
||||
newAttributes: { key: string; value: string }[];
|
||||
} else {
|
||||
// New attribute - will detect type on creation
|
||||
newAttributes.push({ key, value });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (emailExists) {
|
||||
messages.push("The email already exists for this environment and was not updated.");
|
||||
messages.push({ code: "email_already_exists", params: {} });
|
||||
}
|
||||
|
||||
if (userIdExists) {
|
||||
messages.push("The userId already exists for this environment and was not updated.");
|
||||
messages.push({ code: "userid_already_exists", params: {} });
|
||||
}
|
||||
|
||||
// Update all existing attributes
|
||||
// Update all existing attributes with typed column values
|
||||
if (existingAttributes.length > 0) {
|
||||
await prisma.$transaction(
|
||||
existingAttributes.map(({ attributeKeyId, value }) =>
|
||||
existingAttributes.map(({ attributeKeyId, columns }) =>
|
||||
prisma.contactAttribute.upsert({
|
||||
where: {
|
||||
contactId_attributeKeyId: {
|
||||
@@ -204,11 +277,17 @@ export const updateAttributes = async (
|
||||
attributeKeyId,
|
||||
},
|
||||
},
|
||||
update: { value },
|
||||
update: {
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
create: {
|
||||
contactId,
|
||||
attributeKeyId,
|
||||
value,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -217,35 +296,85 @@ export const updateAttributes = async (
|
||||
|
||||
// Then, try to create new attributes if any exist
|
||||
if (newAttributes.length > 0) {
|
||||
const totalAttributeClassesLength = contactAttributeKeys.length + newAttributes.length;
|
||||
// Validate that new attribute keys are safe identifiers
|
||||
const validNewAttributes: typeof newAttributes = [];
|
||||
const invalidKeys: string[] = [];
|
||||
|
||||
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
||||
// Add warning to details about skipped attributes
|
||||
messages.push(
|
||||
`Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
|
||||
for (const attr of newAttributes) {
|
||||
if (isSafeIdentifier(attr.key)) {
|
||||
validNewAttributes.push(attr);
|
||||
} else {
|
||||
invalidKeys.push(attr.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Add error message for invalid keys
|
||||
if (invalidKeys.length > 0) {
|
||||
errors.push({
|
||||
code: "invalid_attribute_keys",
|
||||
params: { keys: invalidKeys.join(", ") },
|
||||
});
|
||||
logger.warn(
|
||||
{ environmentId, invalidKeys },
|
||||
"SDK tried to create attributes with invalid keys - skipping"
|
||||
);
|
||||
} else {
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
newAttributes.map(({ key, value }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
type: "custom",
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: { contactId, value },
|
||||
}
|
||||
|
||||
if (validNewAttributes.length > 0) {
|
||||
const totalAttributeClassesLength = contactAttributeKeys.length + validNewAttributes.length;
|
||||
|
||||
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
|
||||
// Add warning to details about skipped attributes
|
||||
messages.push({
|
||||
code: "attribute_limit_exceeded",
|
||||
params: {
|
||||
count: validNewAttributes.length.toString(),
|
||||
limit: MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT.toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Prepare new attributes with SDK-specific type detection
|
||||
const preparedNewAttributes = validNewAttributes.map(({ key, value }) => {
|
||||
const { dataType, columns } = prepareNewSDKAttributeForStorage(value);
|
||||
return { key, dataType, columns };
|
||||
});
|
||||
|
||||
// Log new attribute creation with their types
|
||||
for (const { key, dataType } of preparedNewAttributes) {
|
||||
logger.info({ environmentId, attributeKey: key, dataType }, "Created new contact attribute");
|
||||
messages.push({ code: "new_attribute_created", params: { key, dataType } });
|
||||
}
|
||||
|
||||
// Create new attributes since we're under the limit
|
||||
await prisma.$transaction(
|
||||
preparedNewAttributes.map(({ key, dataType, columns }) =>
|
||||
prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key,
|
||||
name: formatSnakeCaseToTitleCase(key),
|
||||
type: "custom",
|
||||
dataType,
|
||||
environment: { connect: { id: environmentId } },
|
||||
attributes: {
|
||||
create: {
|
||||
contactId,
|
||||
value: columns.value,
|
||||
valueNumber: columns.valueNumber,
|
||||
valueDate: columns.valueDate,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: messages.length > 0 ? messages : undefined,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
ignoreEmailAttribute,
|
||||
ignoreUserIdAttribute,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user