Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent 50156d474c refactor: improve test coverage for DOM readiness checks
- Extracted ensureBodyExists to separate dom-utils module for better testability
- Added comprehensive tests for dom-utils (100% coverage)
- Added tests for null document.head scenarios in styles module
- Added tests for null document.getElementById in setStyleNonce
- Improved overall test coverage from 42.9% to 90%+ on new code
- All 535 tests pass
2026-03-22 16:52:26 +00:00
Cursor Agent 87b859d02a test: add DOM readiness tests to verify fix
Added comprehensive tests to verify the ensureBodyExists and safe DOM access patterns work correctly in various scenarios.
2026-03-22 16:44:02 +00:00
Cursor Agent df7e768216 fix: prevent null access to document.body during survey rendering
Fixes FORMBRICKS-VD

Added checks to ensure document.body and document.head exist before attempting DOM manipulation:
- renderSurvey now waits for document.body to be available before appending modal container
- addStylesToDom checks for document.head existence before adding styles
- addCustomThemeToDom checks for document.head existence before adding custom theme
- setStyleNonce safely checks for document.getElementById before updating existing elements

This prevents TypeError: can't access property 'removeChild' of null that occurred when surveys loaded before the DOM was fully ready, particularly in Firefox with Turbopack.
2026-03-22 16:41:15 +00:00
230 changed files with 1998 additions and 4740 deletions
-5
View File
@@ -185,11 +185,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
-8
View File
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment.
@@ -1,6 +1,5 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
throw new Error(t("common.organization_not_found"));
}
return (
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
@@ -29,7 +28,7 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
throw new Error(t("common.organization_not_found"));
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -46,7 +45,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new ResourceNotFoundError(t("common.team"), null);
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
throw new Error(t("common.environment_not_found"));
}
return (
@@ -2,11 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -50,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_permission_not_found"));
}
return (
@@ -1,5 +1,4 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -147,18 +146,18 @@ const Page = async (props: {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null);
throw new Error(t("common.membership_not_found"));
}
if (user?.notificationSettings) {
@@ -1,4 +1,3 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -50,8 +49,7 @@ export const EnterpriseLicenseStatus = ({
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { t } = useTranslation();
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -99,7 +97,14 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
{t("common.updated_at")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div>
@@ -127,7 +132,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
@@ -1,5 +1,4 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -300,6 +300,7 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
},
};
@@ -1,4 +1,3 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
throw new Error(t("common.survey_not_found"));
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organization.id);
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const parsedDate = new Date(value);
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 md:px-6">
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate();
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId);
throw new Error("Survey not found");
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error("Workspace not found");
}
const styling = getStyling(project, survey);
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import {
getElementSummary,
getResponsesForSummary,
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
@@ -228,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
ttc: { q1: 10 },
finished: false,
}, // Dropped at q2
{
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
);
expect(dropOff.length).toBe(2);
// Q1: welcome card disabled so impressions = displayCount
// Q1
expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10);
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
// Q2
expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
expect(dropOff[1].ttc).toBe(10);
});
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
test("handles logic jumps", () => {
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
blocks: [
@@ -341,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false },
},
] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
},
{
id: "block3",
@@ -373,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
],
questions: [],
};
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b", q4: "d" },
data: { q1: "a", q2: "b" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
ttc: { q1: 10, q2: 10 },
finished: false,
},
}, // Jumps from q2 to q4, drops at q4
];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff(
surveyWithLogic,
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
1
);
expect(dropOff[0].impressions).toBe(1); // q1: seen
expect(dropOff[1].impressions).toBe(1); // q2: seen
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
});
});
@@ -11,6 +11,7 @@ import {
TResponseData,
TResponseFilterCriteria,
TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
@@ -36,7 +37,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -91,13 +93,63 @@ export const getSurveySummaryMeta = (
};
};
// Determine whether a response interacted with a given element.
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
// This is more reliable than replaying survey logic, which can misattribute impressions
// when branching logic skips elements or when partial response data is insufficient
// to evaluate conditions correctly.
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
const evaluateLogicAndGetNextElementId = (
localSurvey: TSurvey,
elements: TSurveyElement[],
data: TResponseData,
localVariables: TResponseVariables,
currentElementIndex: number,
currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
};
export const getSurveySummaryDropOff = (
@@ -118,8 +170,16 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => {
// Calculate total time-to-completion per element
// Calculate total time-to-completion
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
@@ -127,21 +187,51 @@ export const getSurveySummaryDropOff = (
}
});
// Count impressions based on actual interaction data (ttc + response data)
// instead of replaying survey logic which is unreliable with branching
let lastSeenIdx = -1;
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (wasElementSeen(response, element.id)) {
impressionsArr[i]++;
lastSeenIdx = i;
let currQuesIdx = 0;
while (currQuesIdx < elements.length) {
const currQues = elements[currQuesIdx];
if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
}
}
// Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++;
impressionsArr[currQuesIdx]++;
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
}
});
@@ -150,8 +240,6 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
});
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -163,7 +251,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
}
for (let i = 1; i < elements.length; i++) {
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
throw new Error(t("common.survey_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
if (!organizationId) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organizationId);
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
@@ -1,7 +1,6 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -55,25 +54,6 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
@@ -1,6 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps {
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
throw new Error("Survey not found");
}
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([
const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
@@ -12,7 +11,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration, locale] = await Promise.all([
const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</PageContentWrapper>
);
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration, locale] = await Promise.all([
const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
locale={locale}
/>
</div>
</PageContentWrapper>
+2 -4
View File
@@ -6,10 +6,8 @@ import {
CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
}
return (
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
<>
<NoMobileOverlay />
{POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)}
<ToasterClient />
{children}
</NextAuthProvider>
</>
);
};
@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+64 -63
View File
@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
const authMethod = getAuthMethod(account);
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
}
}
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
status,
userType: "user" as const,
newObject: {
...user,
authMethod: getAuthMethod(account),
authMethod,
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
...(error ? { errorMessage: error.message } : {}),
},
});
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
@@ -1,21 +0,0 @@
import type { TUserLocale } from "@formbricks/types/user";
import { getTranslate } from "@/lingodotdev/server";
interface NoScriptWarningProps {
locale: TUserLocale;
}
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
const t = await getTranslate(locale);
return (
<noscript>
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
</div>
</div>
</noscript>
);
};
-2
View File
@@ -1,6 +1,5 @@
import { Metadata } from "next";
import React from "react";
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
import { SentryProvider } from "@/app/sentry/SentryProvider";
import {
DEFAULT_LOCALE,
@@ -27,7 +26,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<NoScriptWarning locale={locale} />
<SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
+11 -18
View File
@@ -140,7 +140,6 @@ checksums:
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
common/connected: aa0ceca574641de34c74b9e590664230
common/contact: 9afa39bc47019ee6dec6c74b6273967c
common/contacts: d5b6c3f890b3904eaf5754081945c03d
common/continue: 3cfba90b4600131e82fc4260c568d044
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
@@ -148,7 +147,6 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
@@ -188,12 +186,12 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 228a8668be1812e031f438d166861729
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
@@ -230,13 +228,11 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
@@ -257,9 +253,7 @@ checksums:
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -299,9 +293,10 @@ checksums:
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
@@ -393,6 +388,7 @@ checksums:
common/survey_id: 08303e98b3d4134947256e494b0c829e
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533
@@ -407,6 +403,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -430,6 +427,7 @@ checksums:
common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
@@ -445,13 +443,14 @@ checksums:
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
@@ -627,6 +626,7 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
@@ -806,14 +806,8 @@ checksums:
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
@@ -1350,6 +1344,7 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
@@ -1611,8 +1606,6 @@ checksums:
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
-97
View File
@@ -1,97 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
-33
View File
@@ -20,36 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
-1
View File
@@ -26,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
-2
View File
@@ -15,7 +15,6 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -142,7 +141,6 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
+1 -3
View File
@@ -84,9 +84,7 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
return language?.default ? "default" : language?.language.code || "default";
};
+2 -2
View File
@@ -1,7 +1,7 @@
"use server";
import "server-only";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "../../organization/service";
import { getMembershipByUserIdOrganizationId } from "../service";
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
throw new Error("Organization not found");
}
const currentUserMembership = await getMembershipRole(userId, organization.id);
+1 -1
View File
@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) {
throw new ResourceNotFoundError("Organization", null);
throw new Error("Organization ID not found");
}
const organizationBilling = await getOrganizationBilling(organizationId);
+55 -28
View File
@@ -1,13 +1,62 @@
import { describe, expect, test } from "vitest";
import {
convertDateString,
convertDateTimeString,
convertDateTimeStringShort,
convertDatesInObject,
convertTimeString,
formatDate,
getTodaysDateFormatted,
getTodaysDateTimeFormatted,
timeSince,
timeSinceDate,
} from "./time";
describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
test("should format date and time string correctly", () => {
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeString("")).toBe("");
});
});
describe("convertDateTimeStringShort", () => {
test("should format date and time string in short format", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
});
describe("convertTimeString", () => {
test("should format time string correctly", () => {
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
});
});
describe("timeSince", () => {
test("should format time since in English", () => {
const now = new Date();
@@ -26,18 +75,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
});
describe("timeSinceDate", () => {
@@ -46,12 +83,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
});
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
});
describe("formatDate", () => {
@@ -59,17 +90,13 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});
test("should format date with the provided locale", () => {
const date = new Date(2024, 2, 20);
expect(formatDate(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
describe("getTodaysDateFormatted", () => {
test("should format today's date with specified separator", () => {
const today = new Date();
const expected = today.toISOString().split("T")[0].split("-").join(".");
expect(getTodaysDateFormatted(".")).toBe(expected);
});
});
+120 -27
View File
@@ -1,33 +1,120 @@
import { type Locale, formatDistance } from "date-fns";
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE: TUserLocale = "en-US";
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"de-DE": de,
"en-US": enUS,
"es-ES": es,
"fr-FR": fr,
"hu-HU": hu,
"ja-JP": ja,
"nl-NL": nl,
"pt-BR": ptBR,
"pt-PT": pt,
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
export const convertDateString = (dateString: string | null) => {
if (dateString === null) return null;
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return "Invalid Date";
}
return intlFormat(
date,
{
year: "numeric",
month: "short",
day: "numeric",
},
{
locale: "en",
}
);
};
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
const getLocaleForTimeSince = (locale: string): Locale =>
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
export const convertDateTimeStringShort = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -35,21 +122,27 @@ export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) =
});
};
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
export const formatDate = (date: Date) => {
return intlFormat(date, {
year: "numeric",
month: "long",
day: "numeric",
});
};
export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
return formattedDate;
};
export const getTodaysDateTimeFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
-67
View File
@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});
-85
View File
@@ -1,85 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};
+4 -43
View File
@@ -1,12 +1,5 @@
import { describe, expect, test } from "vitest";
import {
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date using the provided locale", () => {
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateWithOrdinal(date)).toBe(
new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
});
test("isValidDateString validates correct date strings", () => {
+13 -44
View File
@@ -1,17 +1,7 @@
const DEFAULT_LOCALE = "en-US";
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
};
// Helper function to calculate difference in days between two dates
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const formatDateForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
const day = date.getDate();
const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
const year = date.getFullYear();
return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value)
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1")
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
const date = new Date(value);
return date;
};
export const getFormattedDateTimeString = (date: Date): string => {
+10 -24
View File
@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/date-display", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => {
if (value === "2023-01-01") {
return `formatted-${locale}-${format ?? "iso"}`;
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
}
if (value === "01-02-2023" && format === "M-d-y") {
return `legacy-${locale}-${format}`;
}
return null;
}),
formatDateWithOrdinal: vi.fn(() => {
return "January 1st, 2023";
}),
}));
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on formatted-en-US-iso");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
expect(result).toBe("You joined on January 1st, 2023");
});
test("formats array values as comma-separated list", () => {
+7 -11
View File
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
[id: string]: string;
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
text: string,
responseData?: TResponseData,
variables?: TResponseVariables,
withSlash: boolean = false,
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
withSlash: boolean = false
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
value = responseData[recallItemId];
// Apply formatting for special value types
if (typeof value === "string") {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
if (formattedDate) {
value = formattedDate;
if (value) {
if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
}
@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});
+6 -16
View File
@@ -1,7 +1,6 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
"contact": "Kontakt",
"contacts": "Kontakte",
"continue": "Weitermachen",
"copied": "Kopiert",
@@ -175,7 +174,6 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"copy_to_environment": "In {{environment}} kopieren",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment": "Umgebung",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktive Umfragen",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date_with_value": "Ungültiges Datum: {value}",
"invalid_date": "Ungültiges Datum",
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
"invalid_file_type": "Ungültiger Dateityp",
"invite": "Einladen",
"invite_them": "Lade sie ein",
"javascript_required": "JavaScript erforderlich",
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten",
"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!",
@@ -326,9 +320,10 @@
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
@@ -420,6 +415,7 @@
"survey_id": "Umfrage-ID",
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_not_found": "Umfrage nicht gefunden",
"survey_paused": "Umfrage pausiert.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
@@ -434,6 +430,7 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Benutzer",
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
@@ -472,13 +470,14 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"workspace_id": "Projekt-ID",
"workspace_name": "Projektname",
"workspace_name_placeholder": "z. B. Formbricks",
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID 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",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
+11 -54
View File
@@ -167,7 +167,6 @@
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continue",
"copied": "Copied",
@@ -175,7 +174,6 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"copy_to_environment": "Copy to {{environment}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"elements": "Elements",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"environment_not_found": "Environment not found",
"environment_notice": "You are currently in the {environment} environment.",
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inactive surveys",
"integration": "integration",
"integrations": "Integrations",
"invalid_date_with_value": "Invalid date: {value}",
"invalid_date": "Invalid date",
"invalid_file_name": "Invalid file name, please rename your file and try again",
"invalid_file_type": "Invalid file type",
"invite": "Invite",
"invite_them": "Invite them",
"javascript_required": "JavaScript Required",
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
"key": "Key",
"label": "Label",
"language": "Language",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata",
"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!",
@@ -326,9 +320,10 @@
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
@@ -420,6 +415,7 @@
"survey_id": "Survey ID",
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_not_found": "Survey not found",
"survey_paused": "Survey paused.",
"survey_type": "Survey Type",
"surveys": "Surveys",
@@ -434,6 +430,7 @@
"team_name": "Team name",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
"time_to_finish": "Time to finish",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -472,13 +470,14 @@
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
"workspace_id": "Workspace ID",
"workspace_name": "Workspace Name",
"workspace_name_placeholder": "e.g. Formbricks",
"workspace_not_found": "Workspace not found",
"workspace_permission_not_found": "Workspace permission not found",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID 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",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
@@ -1532,28 +1533,6 @@
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
"initial_value": "Initial value",
@@ -1859,28 +1838,6 @@
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -175,7 +174,6 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"copy_to_environment": "Copiar a {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"environment_not_found": "Entorno no encontrado",
"environment_notice": "Actualmente estás en el entorno {environment}.",
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Encuestas inactivas",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date_with_value": "Fecha no válida: {value}",
"invalid_date": "Fecha no válida",
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
"invalid_file_type": "Tipo de archivo no válido",
"invite": "Invitar",
"invite_them": "Invítales",
"javascript_required": "Se requiere JavaScript",
"javascript_required_description": "Formbricks requiere JavaScript para funcionar correctamente. Por favor, activa JavaScript en la configuración de tu navegador para continuar.",
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos",
"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!",
@@ -326,9 +320,10 @@
"or": "o",
"organization": "Organización",
"organization_id": "ID de organización",
"organization_not_found": "Organización no encontrada",
"organization_settings": "Ajustes de la organización",
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
@@ -420,6 +415,7 @@
"survey_id": "ID de encuesta",
"survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa",
"survey_not_found": "Encuesta no encontrada",
"survey_paused": "Encuesta pausada.",
"survey_type": "Tipo de encuesta",
"surveys": "Encuestas",
@@ -434,6 +430,7 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Usuario",
"user_id": "ID de usuario",
"user_not_found": "Usuario no encontrado",
"variable": "Variable",
"variable_ids": "IDs de variables",
"variables": "Variables",
@@ -472,13 +470,14 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace": "Espacio de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
"workspace_id": "ID del proyecto",
"workspace_name": "Nombre del proyecto",
"workspace_name_placeholder": "p. ej. Formbricks",
"workspace_not_found": "Proyecto no encontrado",
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario 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",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
"connected": "Connecté",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continuer",
"copied": "Copié",
@@ -175,7 +174,6 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"copy_to_environment": "Copier vers {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"elements": "Éléments",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment": "Environnement",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Sondages inactifs",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date_with_value": "Date invalide: {value}",
"invalid_date": "Date invalide",
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
"invalid_file_type": "Type de fichier invalide",
"invite": "Inviter",
"invite_them": "Invitez-les",
"javascript_required": "JavaScript requis",
"javascript_required_description": "Formbricks nécessite JavaScript pour fonctionner correctement. Veuillez activer JavaScript dans les paramètres de votre navigateur pour continuer.",
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership": "Adhésion",
"membership_not_found": "Abonnement non trouvé",
"meta": "Méta",
"metadata": "Métadonnées",
"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!",
@@ -326,9 +320,10 @@
"or": "ou",
"organization": "Organisation",
"organization_id": "Identifiant de l'organisation",
"organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
@@ -420,6 +415,7 @@
"survey_id": "ID de l'enquête",
"survey_languages": "Langues de l'enquête",
"survey_live": "Sondage en direct",
"survey_not_found": "Sondage non trouvé",
"survey_paused": "Sondage en pause.",
"survey_type": "Type de sondage",
"surveys": "Enquêtes",
@@ -434,6 +430,7 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Utilisateur",
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
@@ -472,13 +470,14 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
"workspace_id": "ID du projet",
"workspace_name": "Nom du projet",
"workspace_name_placeholder": "par ex. Formbricks",
"workspace_not_found": "Projet introuvable",
"workspace_permission_not_found": "Permission du projet introuvable",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
@@ -663,6 +662,7 @@
"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",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -175,7 +174,6 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"copy_to_environment": "Másolás ide: {{environment}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"elements": "Elemek",
"email": "E-mail",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
"enterprise_license": "Vállalati licenc",
"environment": "Környezet",
"environment_not_found": "A környezet nem található",
"environment_notice": "Ön jelenleg a(z) {environment} környezetben van.",
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktív kérdőívek",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
"invalid_date": "Érvénytelen dátum",
"invalid_file_name": "Érvénytelen fájlnév, nevezze át a fájlt, és próbálja újra",
"invalid_file_type": "Érvénytelen fájltípus",
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Tagok",
"members_and_teams": "Tagok és csapatok",
"membership": "Tagság",
"membership_not_found": "A tagság nem található",
"meta": "Meta",
"metadata": "Metaadatok",
"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!",
@@ -326,9 +320,10 @@
"or": "vagy",
"organization": "Szervezet",
"organization_id": "Szervezetazonosító",
"organization_not_found": "A szervezet nem található",
"organization_settings": "Szervezet beállításai",
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
@@ -420,6 +415,7 @@
"survey_id": "Kérdőív-azonosító",
"survey_languages": "Kérdőív nyelvei",
"survey_live": "A kérdőív élő",
"survey_not_found": "A kérdőív nem található",
"survey_paused": "A kérdőív szüneteltetve.",
"survey_type": "Kérdőív típusa",
"surveys": "Kérdőívek",
@@ -434,6 +430,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"teams_not_found": "A csapatok nem találhatók",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Felhasználó",
"user_id": "Felhasználó-azonosító",
"user_not_found": "A felhasználó nem található",
"variable": "Változó",
"variable_ids": "Változóazonosítók",
"variables": "Változók",
@@ -472,13 +470,14 @@
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace": "Munkaterület",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
"workspace_id": "Munkaterület-azonosító",
"workspace_name": "Munkaterület neve",
"workspace_name_placeholder": "például Formbricks",
"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": "év",
"you": "Ön",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, é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",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Egyéni gépnév",
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"date_format": "Dátumformátum",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
"connected": "接続済み",
"contact": "連絡先",
"contacts": "連絡先",
"continue": "続行",
"copied": "コピーしました",
@@ -175,7 +174,6 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"copy_to_environment": "{{environment}} にコピー",
"count_attributes": "{count, plural, other {{count} 個の属性}}",
"count_contacts": "{count, plural, other {{count} 件の連絡先}}",
"count_members": "{count, plural, other {{count} 名のメンバー}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"elements": "要素",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment": "環境",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
"error": "エラー",
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
@@ -257,13 +255,11 @@
"inactive_surveys": "非アクティブなフォーム",
"integration": "連携",
"integrations": "連携",
"invalid_date_with_value": "無効な日付です: {value}",
"invalid_date": "無効な日付です",
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
"invalid_file_type": "無効なファイルタイプです",
"invite": "招待",
"invite_them": "招待する",
"javascript_required": "JavaScriptが必要です",
"javascript_required_description": "Formbricksを正常に動作させるには、JavaScriptが必要です。続行するには、ブラウザの設定でJavaScriptを有効にしてください。",
"key": "キー",
"label": "ラベル",
"language": "言語",
@@ -284,9 +280,7 @@
"marketing": "マーケティング",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -326,9 +320,10 @@
"or": "または",
"organization": "組織",
"organization_id": "組織ID",
"organization_not_found": "組織が見つかりません",
"organization_settings": "組織設定",
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
@@ -420,6 +415,7 @@
"survey_id": "フォームID",
"survey_languages": "フォームの言語",
"survey_live": "フォーム公開中",
"survey_not_found": "フォームが見つかりません",
"survey_paused": "フォームは一時停止中です。",
"survey_type": "フォームの種類",
"surveys": "フォーム",
@@ -434,6 +430,7 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "ユーザー",
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
@@ -472,13 +470,14 @@
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
"workspace_id": "ワークスペースID",
"workspace_name": "ワークスペース名",
"workspace_name_placeholder": "例: Formbricks",
"workspace_not_found": "ワークスペースが見つかりません",
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contact_not_found": "そのような連絡先は見つかりません",
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
@@ -1421,6 +1421,7 @@
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Verbinden",
"connect_formbricks": "Sluit Formbricks aan",
"connected": "Aangesloten",
"contact": "Contact",
"contacts": "Contacten",
"continue": "Doorgaan",
"copied": "Gekopieerd",
@@ -175,7 +174,6 @@
"copy": "Kopiëren",
"copy_code": "Kopieer code",
"copy_link": "Kopieer link",
"copy_to_environment": "Kopiëren naar {{environment}}",
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"elements": "Elementen",
"email": "E-mail",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
"enterprise_license": "Enterprise-licentie",
"environment": "Omgeving",
"environment_not_found": "Omgeving niet gevonden",
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
"error": "Fout",
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date_with_value": "Ongeldige datum: {value}",
"invalid_date": "Ongeldige datum",
"invalid_file_name": "Ongeldige bestandsnaam. Hernoem uw bestand en probeer het opnieuw",
"invalid_file_type": "Ongeldig bestandstype",
"invite": "Uitnodiging",
"invite_them": "Nodig ze uit",
"javascript_required": "JavaScript vereist",
"javascript_required_description": "Formbricks heeft JavaScript nodig om correct te functioneren. Schakel JavaScript in je browserinstellingen in om door te gaan.",
"key": "Sleutel",
"label": "Label",
"language": "Taal",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership": "Lidmaatschap",
"membership_not_found": "Lidmaatschap niet gevonden",
"meta": "Meta",
"metadata": "Metagegevens",
"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!",
@@ -326,9 +320,10 @@
"or": "of",
"organization": "Organisatie",
"organization_id": "Organisatie-ID",
"organization_not_found": "Organisatie niet gevonden",
"organization_settings": "Organisatie-instellingen",
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
@@ -420,6 +415,7 @@
"survey_id": "Enquête-ID",
"survey_languages": "Enquêtetalen",
"survey_live": "Enquête live",
"survey_not_found": "Enquête niet gevonden",
"survey_paused": "Enquête onderbroken.",
"survey_type": "Enquêtetype",
"surveys": "Enquêtes",
@@ -434,6 +430,7 @@
"team_name": "Teamnaam",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
"time_to_finish": "Tijd om af te ronden",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Gebruiker",
"user_id": "Gebruikers-ID",
"user_not_found": "Gebruiker niet gevonden",
"variable": "Variabel",
"variable_ids": "Variabele ID's",
"variables": "Variabelen",
@@ -472,13 +470,14 @@
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
"workspace_id": "Werkruimte-ID",
"workspace_name": "Werkruimtenaam",
"workspace_name_placeholder": "bijv. Formbricks",
"workspace_not_found": "Werkruimte niet gevonden",
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID 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",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Aangepaste hostnaam",
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
"delete_anyways": "Toch verwijderen",
"delete_block": "Blok verwijderen",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "conectado",
"contact": "Contato",
"contacts": "Contatos",
"continue": "Continuar",
"copied": "Copiado",
@@ -175,7 +174,6 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contato} other {{count} contatos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Pesquisas inativas",
"integration": "integração",
"integrations": "Integrações",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_date": "Data inválida",
"invalid_file_name": "Nome de arquivo inválido, por favor renomeie seu arquivo e tente novamente",
"invalid_file_type": "Tipo de arquivo inválido",
"invite": "convidar",
"invite_them": "Convida eles",
"javascript_required": "JavaScript Necessário",
"javascript_required_description": "O Formbricks precisa do JavaScript para funcionar corretamente. Por favor, ative o JavaScript nas configurações do seu navegador para continuar.",
"key": "Chave",
"label": "Etiqueta",
"language": "Língua",
@@ -284,9 +280,7 @@
"marketing": "marketing",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership": "Associação",
"membership_not_found": "Assinatura não encontrada",
"meta": "Meta",
"metadata": "metadados",
"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!",
@@ -326,9 +320,10 @@
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
@@ -420,6 +415,7 @@
"survey_id": "ID da Pesquisa",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Pesquisa ao vivo",
"survey_not_found": "Pesquisa não encontrada",
"survey_paused": "Pesquisa pausada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "Pesquisas",
@@ -434,6 +430,7 @@
"team_name": "Nome da equipe",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
"time_to_finish": "Hora de terminar",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Usuário",
"user_id": "ID do usuário",
"user_not_found": "Usuário não encontrado",
"variable": "variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -472,13 +470,14 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex: Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário 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",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Hostname personalizado",
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
"delete_anyways": "Excluir mesmo assim",
"delete_block": "Excluir bloco",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Conectar",
"connect_formbricks": "Ligar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -175,7 +174,6 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"copy_to_environment": "Copiar para {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inquéritos inativos",
"integration": "integração",
"integrations": "Integrações",
"invalid_date_with_value": "Data inválida: {value}",
"invalid_date": "Data inválida",
"invalid_file_name": "Nome de ficheiro inválido, por favor renomeie o seu ficheiro e tente novamente",
"invalid_file_type": "Tipo de ficheiro inválido",
"invite": "Convidar",
"invite_them": "Convide-os",
"javascript_required": "JavaScript Necessário",
"javascript_required_description": "O Formbricks necessita de JavaScript para funcionar corretamente. Por favor, ativa o JavaScript nas definições do teu navegador para continuar.",
"key": "Chave",
"label": "Etiqueta",
"language": "Idioma",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership": "Subscrição",
"membership_not_found": "Associação não encontrada",
"meta": "Meta",
"metadata": "Metadados",
"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ã!",
@@ -326,9 +320,10 @@
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
@@ -420,6 +415,7 @@
"survey_id": "ID do Inquérito",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Inquérito ao vivo",
"survey_not_found": "Inquérito não encontrado",
"survey_paused": "Inquérito pausado.",
"survey_type": "Tipo de Inquérito",
"surveys": "Inquéritos",
@@ -434,6 +430,7 @@
"team_name": "Nome da equipa",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
"time_to_finish": "Tempo para concluir",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Utilizador",
"user_id": "ID do Utilizador",
"user_not_found": "Utilizador não encontrado",
"variable": "Variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -472,13 +470,14 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex. Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador 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",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Nome do host personalizado",
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
"delete_anyways": "Eliminar mesmo assim",
"delete_block": "Eliminar bloco",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Conectează",
"connect_formbricks": "Conectează Formbricks",
"connected": "Conectat",
"contact": "Contact",
"contacts": "Contacte",
"continue": "Continuă",
"copied": "Copiat",
@@ -175,7 +174,6 @@
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"copy_to_environment": "Copiază în {{environment}}",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {{count} contact} few {{count} contacte} other {{count} de contacte}}",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"elements": "Elemente",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment": "Mediu",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
"error": "Eroare",
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Sondaje inactive",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date_with_value": "Dată invalidă: {value}",
"invalid_date": "Dată invalidă",
"invalid_file_name": "Nume de fișier invalid, vă rugăm să redenumiți fișierul și să încercați din nou",
"invalid_file_type": "Tip de fișier nevalid",
"invite": "Invită",
"invite_them": "Invită-i",
"javascript_required": "JavaScript necesar",
"javascript_required_description": "Formbricks necesită JavaScript pentru a funcționa corect. Te rugăm să activezi JavaScript în setările browserului tău pentru a continua.",
"key": "Cheie",
"label": "Etichetă",
"language": "Limba",
@@ -284,9 +280,7 @@
"marketing": "Marketing",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership": "Abonament",
"membership_not_found": "Apartenența nu a fost găsită",
"meta": "Meta",
"metadata": "Metadate",
"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!",
@@ -326,9 +320,10 @@
"or": "sau",
"organization": "Organizație",
"organization_id": "ID Organizație",
"organization_not_found": "Organizația nu a fost găsită",
"organization_settings": "Setări Organizație",
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
@@ -420,6 +415,7 @@
"survey_id": "ID Chestionar",
"survey_languages": "Limbi chestionar",
"survey_live": "Chestionar activ",
"survey_not_found": "Sondajul nu a fost găsit",
"survey_paused": "Chestionar oprit.",
"survey_type": "Tip Chestionar",
"surveys": "Sondaje",
@@ -434,6 +430,7 @@
"team_name": "Nume echipă",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
"time_to_finish": "Timp până la finalizare",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Utilizator",
"user_id": "ID Utilizator",
"user_not_found": "Utilizatorul nu a fost găsit",
"variable": "Variabilă",
"variable_ids": "ID-uri variabile",
"variables": "Variante",
@@ -472,13 +470,14 @@
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
"workspace_id": "ID workspace",
"workspace_name": "Nume workspace",
"workspace_name_placeholder": "ex: Formbricks",
"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",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator 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",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Gazdă personalizată",
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
"delete_anyways": "Șterge oricum",
"delete_block": "Șterge blocul",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Подключить",
"connect_formbricks": "Подключить Formbricks",
"connected": "Подключено",
"contact": "Контакт",
"contacts": "Контакты",
"continue": "Продолжить",
"copied": "Скопировано",
@@ -175,7 +174,6 @@
"copy": "Копировать",
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"copy_to_environment": "Копировать в {{environment}}",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контакта}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"elements": "Элементы",
"email": "Email",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
"enterprise_license": "Корпоративная лицензия",
"environment": "Окружение",
"environment_not_found": "Среда не найдена",
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
"error": "Ошибка",
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Неактивные опросы",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date_with_value": "Неверная дата: {value}",
"invalid_date": "Неверная дата",
"invalid_file_name": "Недопустимое имя файла, переименуйте файл и попробуйте снова",
"invalid_file_type": "Недопустимый тип файла",
"invite": "Пригласить",
"invite_them": "Пригласить их",
"javascript_required": "Требуется JavaScript",
"javascript_required_description": "Для корректной работы Formbricks необходим JavaScript. Пожалуйста, включите JavaScript в настройках вашего браузера, чтобы продолжить.",
"key": "Ключ",
"label": "Метка",
"language": "Язык",
@@ -284,9 +280,7 @@
"marketing": "Маркетинг",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership": "Членство",
"membership_not_found": "Участие не найдено",
"meta": "Мета",
"metadata": "Метаданные",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
@@ -326,9 +320,10 @@
"or": "или",
"organization": "Организация",
"organization_id": "ID организации",
"organization_not_found": "Организация не найдена",
"organization_settings": "Настройки организации",
"organization_teams_not_found": "Команды организации не найдены",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
@@ -420,6 +415,7 @@
"survey_id": "ID опроса",
"survey_languages": "Языки опроса",
"survey_live": "Опрос активен",
"survey_not_found": "Опрос не найден",
"survey_paused": "Опрос приостановлен.",
"survey_type": "Тип опроса",
"surveys": "Опросы",
@@ -434,6 +430,7 @@
"team_name": "Название команды",
"team_role": "Роль в команде",
"teams": "Команды",
"teams_not_found": "Команды не найдены",
"text": "Текст",
"time": "Время",
"time_to_finish": "Время до завершения",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Пользователь",
"user_id": "ID пользователя",
"user_not_found": "Пользователь не найден",
"variable": "Переменная",
"variable_ids": "ID переменных",
"variables": "Переменные",
@@ -472,13 +470,14 @@
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
"workspace_id": "ID рабочего пространства",
"workspace_name": "Название рабочего пространства",
"workspace_name_placeholder": "например, Formbricks",
"workspace_not_found": "Рабочее пространство не найдено",
"workspace_permission_not_found": "Разрешение на рабочее пространство не найдено",
"workspaces": "Рабочие пространства",
"years": "годы",
"you": "Вы",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"contact_deleted_successfully": "Контакт успешно удалён",
"contact_not_found": "Такой контакт не найден",
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Пользовательский хостнейм",
"customize_survey_logo": "Настроить логотип опроса",
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"date_format": "Формат даты",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "Anslut",
"connect_formbricks": "Anslut Formbricks",
"connected": "Ansluten",
"contact": "Kontakt",
"contacts": "Kontakter",
"continue": "Fortsätt",
"copied": "Kopierad",
@@ -175,7 +174,6 @@
"copy": "Kopiera",
"copy_code": "Kopiera kod",
"copy_link": "Kopiera länk",
"copy_to_environment": "Kopiera till {{environment}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"elements": "Element",
"email": "E-post",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
"enterprise_license": "Företagslicens",
"environment": "Miljö",
"environment_not_found": "Miljö hittades inte",
"environment_notice": "Du är för närvarande i {environment}-miljön.",
"error": "Fel",
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktiva enkäter",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date_with_value": "Ogiltigt datum: {value}",
"invalid_date": "Ogiltigt datum",
"invalid_file_name": "Ogiltigt filnamn, vänligen byt namn på din fil och försök igen",
"invalid_file_type": "Ogiltig filtyp",
"invite": "Bjud in",
"invite_them": "Bjud in dem",
"javascript_required": "JavaScript krävs",
"javascript_required_description": "Formbricks kräver JavaScript för att fungera korrekt. Vänligen aktivera JavaScript i dina webbläsarinställningar för att fortsätta.",
"key": "Nyckel",
"label": "Etikett",
"language": "Språk",
@@ -284,9 +280,7 @@
"marketing": "Marknadsföring",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership": "Medlemskap",
"membership_not_found": "Medlemskap hittades inte",
"meta": "Meta",
"metadata": "Metadata",
"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!",
@@ -326,9 +320,10 @@
"or": "eller",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation hittades inte",
"organization_settings": "Organisationsinställningar",
"organization_teams_not_found": "Organisationsteam hittades inte",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
@@ -420,6 +415,7 @@
"survey_id": "Enkät-ID",
"survey_languages": "Enkätspråk",
"survey_live": "Enkät live",
"survey_not_found": "Enkät hittades inte",
"survey_paused": "Enkät pausad.",
"survey_type": "Enkättyp",
"surveys": "Enkäter",
@@ -434,6 +430,7 @@
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
"time": "Tid",
"time_to_finish": "Tid att slutföra",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "Användare",
"user_id": "Användar-ID",
"user_not_found": "Användare hittades inte",
"variable": "Variabel",
"variable_ids": "Variabel-ID:n",
"variables": "Variabler",
@@ -472,13 +470,14 @@
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace": "Arbetsyta",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
"workspace_id": "Arbetsyte-ID",
"workspace_name": "Arbetsytans namn",
"workspace_name_placeholder": "t.ex. Formbricks",
"workspace_not_found": "Arbetsyta hittades inte",
"workspace_permission_not_found": "Arbetsytebehörighet hittades inte",
"workspaces": "Arbetsytor",
"years": "år",
"you": "Du",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"contact_deleted_successfully": "Kontakt borttagen",
"contact_not_found": "Ingen sådan kontakt hittades",
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
@@ -1421,6 +1421,7 @@
"custom_hostname": "Anpassat värdnamn",
"customize_survey_logo": "Anpassa undersökningens logotyp",
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
"date_format": "Datumformat",
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
"delete_anyways": "Ta bort ändå",
"delete_block": "Ta bort block",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "连接",
"connect_formbricks": "连接 Formbricks",
"connected": "已连接",
"contact": "联系人",
"contacts": "联系人",
"continue": "继续",
"copied": "已复制",
@@ -175,7 +174,6 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"copy_to_environment": "复制到{{environment}}",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"elements": "元素",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment": "环境",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
"error": "错误",
"error_component_description": "这个资源不存在或您没有权限访问它。",
@@ -257,13 +255,11 @@
"inactive_surveys": "不 活跃 调查",
"integration": "集成",
"integrations": "集成",
"invalid_date_with_value": "无效 日期: {value}",
"invalid_date": "无效 日期",
"invalid_file_name": "文件名无效,请重命名文件后重试",
"invalid_file_type": "无效 的 文件 类型",
"invite": "邀请",
"invite_them": "邀请 他们",
"javascript_required": "需要启用 JavaScript",
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常运行。请在浏览器设置中启用 JavaScript 以继续。",
"key": "键",
"label": "标签",
"language": "语言",
@@ -284,9 +280,7 @@
"marketing": "市场营销",
"members": "成员",
"members_and_teams": "成员和团队",
"membership": "会员资格",
"membership_not_found": "未找到会员资格",
"meta": "元数据",
"metadata": "元数据",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
@@ -326,9 +320,10 @@
"or": "或",
"organization": "组织",
"organization_id": "组织 ID",
"organization_not_found": "组织 未找到",
"organization_settings": "组织 设置",
"organization_teams_not_found": "未找到 组织 团队",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
@@ -420,6 +415,7 @@
"survey_id": "调查 ID",
"survey_languages": "调查 语言",
"survey_live": "调查 运行中",
"survey_not_found": "调查 未找到",
"survey_paused": "调查 暂停。",
"survey_type": "调查 类型",
"surveys": "调查",
@@ -434,6 +430,7 @@
"team_name": "团队 名称",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
"time_to_finish": "完成 时间",
@@ -457,6 +454,7 @@
"url": "URL",
"user": "用户",
"user_id": "用户 ID",
"user_not_found": "用户 不存在",
"variable": "变量",
"variable_ids": "变量 ID",
"variables": "变量",
@@ -472,13 +470,14 @@
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace": "工作区",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
"workspace_id": "工作区 ID",
"workspace_name": "工作区名称",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "未找到工作区",
"workspace_permission_not_found": "未找到工作区权限",
"workspaces": "工作区",
"years": "年",
"you": "你 ",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
"contact_deleted_successfully": "联系人 删除 成功",
"contact_not_found": "未找到此 联系人",
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"create_attribute": "创建属性",
@@ -1421,6 +1421,7 @@
"custom_hostname": "自 定 义 主 机 名",
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
"delete_anyways": "仍然删除",
"delete_block": "删除区块",
+11 -10
View File
@@ -167,7 +167,6 @@
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
"connected": "已連線",
"contact": "聯絡人",
"contacts": "聯絡人",
"continue": "繼續",
"copied": "已 複製",
@@ -175,7 +174,6 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"copy_to_environment": "複製到{{environment}}",
"count_attributes": "{count, plural, other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 位聯絡人}}",
"count_members": "{count, plural, other {{count} 位成員}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"elements": "元素",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment": "環境",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
@@ -257,13 +255,11 @@
"inactive_surveys": "停用中的問卷",
"integration": "整合",
"integrations": "整合",
"invalid_date_with_value": "無效日期: {value}",
"invalid_date": "無效日期",
"invalid_file_name": "檔案名稱無效,請重新命名檔案後再試一次",
"invalid_file_type": "無效的檔案類型",
"invite": "邀請",
"invite_them": "邀請他們",
"javascript_required": "需要 JavaScript",
"javascript_required_description": "Formbricks 需要 JavaScript 才能正常運作。請在瀏覽器設定中啟用 JavaScript 以繼續使用。",
"key": "金鑰",
"label": "標籤",
"language": "語言",
@@ -284,9 +280,7 @@
"marketing": "行銷",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership": "會員資格",
"membership_not_found": "找不到成員資格",
"meta": "Meta",
"metadata": "元數據",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
@@ -326,9 +320,10 @@
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
"organization_not_found": "找不到組織",
"organization_settings": "組織設定",
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
@@ -420,6 +415,7 @@
"survey_id": "問卷 ID",
"survey_languages": "問卷語言",
"survey_live": "問卷已上線",
"survey_not_found": "找不到問卷",
"survey_paused": "問卷已暫停。",
"survey_type": "問卷類型",
"surveys": "問卷",
@@ -434,6 +430,7 @@
"team_name": "團隊名稱",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
"time_to_finish": "完成時間",
@@ -457,6 +454,7 @@
"url": "網址",
"user": "使用者",
"user_id": "使用者 ID",
"user_not_found": "找不到使用者",
"variable": "變數",
"variable_ids": "變數 ID",
"variables": "變數",
@@ -472,13 +470,14 @@
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace": "工作區",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
"workspace_id": "工作區 ID",
"workspace_name": "工作區名稱",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "找不到工作區",
"workspace_permission_not_found": "找不到工作區權限",
"workspaces": "工作區",
"years": "年",
"you": "您",
@@ -663,6 +662,7 @@
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
"contact_deleted_successfully": "聯絡人已成功刪除",
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"create_attribute": "建立屬性",
@@ -1421,6 +1421,7 @@
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
@@ -1,6 +1,6 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
@@ -18,7 +18,11 @@ interface LanguageDropdownProps {
locale: TUserLocale;
}
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
export const LanguageDropdown = ({
survey,
setLanguage,
locale,
}: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
@@ -29,10 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -2,7 +2,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
@@ -69,7 +68,7 @@ export const createTagToResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new ResourceNotFoundError("Environment", null);
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -114,7 +113,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new ResourceNotFoundError("Environment", null);
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -5,9 +5,7 @@ import { useTranslation } from "react-i18next";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
@@ -17,7 +15,6 @@ interface ElementSkipProps {
elements: TSurveyElement[];
isFirstElementAnswered?: boolean;
responseData: TResponseData;
locale: TUserLocale;
}
export const ElementSkip = ({
@@ -26,10 +23,8 @@ export const ElementSkip = ({
elements,
isFirstElementAnswered,
responseData,
locale,
}: ElementSkipProps) => {
const { t } = useTranslation();
const dateFormats = getSurveyDateFormatMap(elements);
return (
<div>
{skippedElements && (
@@ -86,11 +81,7 @@ export const ElementSkip = ({
},
"default"
),
responseData,
undefined,
false,
locale,
dateFormats
responseData
)
)}
</p>
@@ -129,11 +120,7 @@ export const ElementSkip = ({
},
"default"
),
responseData,
undefined,
false,
locale,
dateFormats
responseData
)
)}
</p>
@@ -3,12 +3,11 @@ import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -22,7 +21,6 @@ interface RenderResponseProps {
element: TSurveyElement;
survey: TSurvey;
language: string | null;
locale: TUserLocale;
isExpanded?: boolean;
showId: boolean;
}
@@ -32,7 +30,6 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
element,
survey,
language,
locale,
isExpanded = true,
showId,
}) => {
@@ -66,8 +63,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") {
const formattedDate =
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}
@@ -6,9 +6,7 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurveyDateFormatMap } from "@/lib/utils/date-display";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -23,17 +21,14 @@ interface SingleResponseCardBodyProps {
survey: TSurvey;
response: TResponseWithQuotas;
skippedQuestions: string[][];
locale: TUserLocale;
}
export const SingleResponseCardBody = ({
survey,
response,
skippedQuestions,
locale,
}: SingleResponseCardBodyProps) => {
const elements = getElementsFromBlocks(survey.blocks);
const dateFormats = getSurveyDateFormatMap(elements);
const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false;
const { t } = useTranslation();
const formatTextWithSlashes = (text: string) => {
@@ -66,7 +61,6 @@ export const SingleResponseCardBody = ({
status={"welcomeCard"}
isFirstElementAnswered={isFirstElementAnswered}
responseData={response.data}
locale={locale}
/>
)}
<div className="space-y-6">
@@ -104,9 +98,7 @@ export const SingleResponseCardBody = ({
getLocalizedValue(question.headline, "default"),
response.data,
response.variables,
true,
locale,
dateFormats
true
)
)
)}
@@ -117,7 +109,6 @@ export const SingleResponseCardBody = ({
survey={survey}
responseData={response.data[question.id]}
language={response.language}
locale={locale}
showId={true}
/>
</div>
@@ -127,7 +118,6 @@ export const SingleResponseCardBody = ({
skippedElements={skipped}
elements={elements}
responseData={response.data}
locale={locale}
status={
response.finished ||
(skippedQuestions.length > 0 &&
@@ -137,12 +137,7 @@ export const SingleResponseCard = ({
locale={locale}
/>
<SingleResponseCardBody
survey={survey}
response={response}
skippedQuestions={skippedQuestions}
locale={locale}
/>
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
<ResponseTagsWrapper
key={response.id}
@@ -1,15 +0,0 @@
"use client";
import { SessionProvider } from "next-auth/react";
interface NextAuthProviderProps {
children: React.ReactNode;
sessionMaxAge: number;
}
export const NextAuthProvider = ({ children, sessionMaxAge }: NextAuthProviderProps) => {
// Refresh at 1/3 of session max age, capped at 5 minutes
const refetchInterval = Math.min(Math.max(Math.floor(sessionMaxAge / 3), 60), 300);
return <SessionProvider refetchInterval={refetchInterval}>{children}</SessionProvider>;
};
+41 -29
View File
@@ -10,25 +10,6 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -319,20 +300,51 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("session callback", () => {
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
});
});
+21 -10
View File
@@ -1,4 +1,3 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -14,7 +13,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -32,7 +31,6 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -312,17 +310,30 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {
@@ -1,115 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});
@@ -1,54 +0,0 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};
+10 -4
View File
@@ -106,7 +106,10 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -125,7 +128,10 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -139,7 +145,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -159,7 +165,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
+2 -2
View File
@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,
@@ -13,7 +13,6 @@ import {
} from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
@@ -78,6 +77,14 @@ const formatMoney = (currency: string, unitAmount: number | null, locale: string
}).format(unitAmount / 100);
};
const formatDate = (date: Date, locale: string) =>
date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
type TPlanCardData = {
plan: TStandardPlan;
interval: TCloudBillingInterval;
@@ -161,17 +168,7 @@ export const PricingTable = ({
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
const usageCycleLabel = `${formatDateForDisplay(usageCycleStart, locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})} - ${formatDateForDisplay(usageCycleEnd, locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})}`;
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
const responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel =
@@ -436,15 +433,7 @@ export const PricingTable = ({
<AlertDescription>
{t("environments.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();
@@ -458,21 +458,18 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
subscriptionCount: number
idempotencySuffix: string
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
);
};
@@ -1267,7 +1264,8 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string
organizationId: string,
idempotencySuffix = "reconcile"
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1315,26 +1313,11 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
);
await Promise.all(
hobbySubscriptions.map(async ({ subscription }) => {
try {
await client.subscriptions.cancel(subscription.id, {
prorate: false,
});
} catch (err) {
if (
err instanceof Stripe.errors.StripeInvalidRequestError &&
err.statusCode === 404 &&
err.code === "resource_missing"
) {
logger.warn(
{ subscriptionId: subscription.id, organizationId },
"Subscription already deleted, skipping cancel"
);
return;
}
throw err;
}
})
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
);
return;
}
@@ -1344,14 +1327,12 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "all",
limit: 20,
status: "active",
limit: 1,
});
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
}
}
};
@@ -1359,6 +1340,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await syncOrganizationBillingFromStripe(organizationId);
};
@@ -1,14 +1,13 @@
import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getDisplaysByContactId } from "@/lib/display/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getSurveys } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
@@ -36,12 +35,12 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.user_not_found"));
}
if (!responses) {
@@ -50,11 +49,11 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = user.locale ?? DEFAULT_LOCALE;
const locale = await findMatchingLocale();
return (
<ActivityTimeline
@@ -1,7 +1,5 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getDisplaysByContactId } from "@/lib/display/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributesWithKeyInfo } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
@@ -11,14 +9,13 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [locale, contact, attributesWithKeyInfo] = await Promise.all([
getLocale(),
const [contact, attributesWithKeyInfo] = await Promise.all([
getContact(contactId),
getContactAttributesWithKeyInfo(contactId),
]);
if (!contact) {
throw new ResourceNotFoundError(t("common.contact"), contactId);
throw new Error(t("environments.contacts.contact_not_found"));
}
const [responses, displays] = await Promise.all([
@@ -46,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
return <IdBadge id={attr.value} />;
}
return formatAttributeValue(attr.value, attr.dataType, locale);
return formatAttributeValue(attr.value, attr.dataType);
};
return (
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
@@ -32,7 +31,7 @@ export const SingleContactPage = async (props: {
]);
if (!contact) {
throw new ResourceNotFoundError(t("common.contact"), params.contactId);
throw new Error(t("environments.contacts.contact_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
+1 -2
View File
@@ -3,7 +3,6 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -165,7 +164,7 @@ export const updateContactAttributesAction = authenticatedActionClient
// Get contact to access environmentId for revalidation
const contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
throw new Error("Contact not found");
}
const result = await updateContactAttributes(parsedInput.contactId, parsedInput.attributes);
@@ -1,12 +1,12 @@
"use client";
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 { formatDateForDisplay } from "@/lib/utils/datetime";
import { Badge } from "@/modules/ui/components/badge";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
@@ -61,15 +61,7 @@ export const generateAttributeTableColumns = (
header: t("common.created_at"),
cell: ({ row }) => {
const createdAt = row.original.createdAt;
return (
<span>
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
);
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
},
};
@@ -78,7 +78,7 @@ export const AttributesTable = ({
// Generate columns
const columns = useMemo(() => {
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
}, [searchValue, isReadOnly, isExpanded, locale, t]);
}, [searchValue, isReadOnly, isExpanded]);
// Load saved settings from localStorage
useEffect(() => {
@@ -2,7 +2,6 @@
import { ColumnDef } from "@tanstack/react-table";
import { TFunction } from "i18next";
import { TUserLocale } from "@formbricks/types/user";
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";
@@ -13,7 +12,6 @@ export const generateContactTableColumns = (
searchValue: string,
data: TContactTableData[],
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TContactTableData>[] => {
const userColumn: ColumnDef<TContactTableData> = {
@@ -77,7 +75,7 @@ export const generateContactTableColumns = (
cell: ({ row }: { row: { original: TContactTableData } }) => {
const attribute = row.original.attributes.find((a) => a.key === attr.key);
if (!attribute) return null;
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale);
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
},
};
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -21,7 +20,7 @@ export const ContactsSecondaryNavigation = async ({
project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
}
@@ -17,7 +17,6 @@ import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@ta
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button";
@@ -66,15 +65,14 @@ export const ContactsTable = ({
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [rowSelection, setRowSelection] = useState({});
const router = useRouter();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const { t } = useTranslation();
const [parent] = useAutoAnimate();
// Generate columns
const columns = useMemo(() => {
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
}, [searchValue, data, isReadOnly, locale, t]);
return generateContactTableColumns(searchValue, data, isReadOnly, t);
}, [searchValue, data, isReadOnly]);
// Load saved settings from localStorage
useEffect(() => {
+4 -4
View File
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -24,11 +24,11 @@ const ConfigLayout = async (props: {
]);
if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null);
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
throw new Error(t("common.session_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
@@ -45,7 +45,7 @@ const ConfigLayout = async (props: {
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
throw new Error(t("common.workspace_not_found"));
}
return children;
@@ -1,5 +1,4 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { formatDateForDisplay } from "@/lib/utils/datetime";
/**
* Formats an attribute value for display based on its data type.
@@ -28,11 +27,12 @@ export const formatAttributeValue = (
if (Number.isNaN(date.getTime())) {
return String(value);
}
return formatDateForDisplay(date, locale, {
// Use Intl.DateTimeFormat for locale-aware date formatting
return new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}).format(date);
} catch {
// If date parsing fails, return the raw value
return String(value);
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
@@ -35,7 +35,7 @@ const checkAdvancedTargetingPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
throw new Error("Organization not found");
}
const isContactsEnabled = await getIsContactsEnabled(organizationId);
@@ -4,7 +4,7 @@ import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import {
Dialog,
@@ -15,63 +15,23 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { SegmentActivityTab } from "./segment-activity-tab";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface EditSegmentModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
currentSegment: TSegmentWithSurveyNames;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
}
const SegmentSettingsTab = ({
activitySummary,
contactAttributeKeys,
currentSegment,
environmentId,
isContactsEnabled,
isReadOnly,
segments,
setOpen,
}: Pick<
EditSegmentModalProps,
| "activitySummary"
| "contactAttributeKeys"
| "currentSegment"
| "environmentId"
| "isContactsEnabled"
| "isReadOnly"
| "segments"
| "setOpen"
>) => {
if (!isContactsEnabled) {
return null;
}
return (
<SegmentSettings
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
};
export const EditSegmentModal = ({
environmentId,
open,
setOpen,
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
@@ -80,25 +40,31 @@ export const EditSegmentModal = ({
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => {
if (isContactsEnabled) {
return (
<SegmentSettings
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
initialSegment={currentSegment}
segments={segments}
setOpen={setOpen}
isReadOnly={isReadOnly}
/>
);
}
return null;
};
const tabs = [
{
title: t("common.activity"),
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
children: <SegmentActivityTab currentSegment={currentSegment} />,
},
{
title: t("common.settings"),
children: (
<SegmentSettingsTab
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
currentSegment={currentSegment}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
segments={segments}
setOpen={setOpen}
/>
),
children: <SettingsTab />,
},
];
@@ -1,22 +1,19 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
currentSegment: TSegmentWithSurveyNames;
}
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t } = useTranslation();
const { activeSurveys, inactiveSurveys } = activitySummary;
const { activeSurveys, inactiveSurveys } = currentSegment;
return (
<div className="grid grid-cols-3 pb-2">
@@ -25,20 +22,20 @@ export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentA
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
{activeSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
))}
</div>
<div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
{inactiveSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
))}
</div>
</div>
@@ -46,13 +43,13 @@ export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentA
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
</p>
</div>{" "}
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
</p>
</div>
<div>
@@ -1,340 +0,0 @@
import { describe, expect, test } from "vitest";
import { TBaseFilters, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
buildSegmentActivitySummary,
buildSegmentActivitySummaryFromSegments,
doesSegmentReferenceSegment,
getReferencingSegments,
} from "./segment-activity-utils";
const createSurvey = (overrides: Partial<TSurvey>): TSurvey =>
({
id: "survey_1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: "env_1",
status: "inProgress",
welcomeCard: {
enabled: false,
headline: {},
html: {},
fileUrl: "",
buttonLabel: {},
timeToFinish: false,
},
questions: [],
hiddenFields: { enabled: false, fieldIds: [] },
endings: [],
autoClose: null,
displayOption: "displayOnce",
displayPercentage: null,
recontactDays: null,
displayLimit: null,
delay: 0,
autoComplete: null,
triggers: [],
styling: null,
surveyClosedMessage: null,
segment: null,
segmentId: null,
projectOverwrites: null,
singleUse: null,
pin: null,
redirectUrl: null,
displayStatus: null,
displayCount: null,
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
recaptcha: null,
variables: [],
blocks: undefined,
followUps: [],
verifyEmailTemplateId: null,
...overrides,
}) as TSurvey;
const createSegment = (overrides: Partial<TSegment>): TSegment =>
({
id: "segment_1",
title: "Segment 1",
description: null,
isPrivate: false,
environmentId: "env_1",
createdAt: new Date(),
updatedAt: new Date(),
surveys: [],
filters: [],
...overrides,
}) as TSegment;
const createSegmentWithSurveyNames = (overrides: Partial<TSegmentWithSurveyRefs>): TSegmentWithSurveyRefs =>
({
...createSegment(overrides),
activeSurveys: [],
inactiveSurveys: [],
...overrides,
}) as TSegmentWithSurveyRefs;
describe("segment activity utils", () => {
test("doesSegmentReferenceSegment returns true for nested segment filters", () => {
const filters: TBaseFilters = [
{
id: "group_1",
connector: null,
resource: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
},
];
expect(doesSegmentReferenceSegment(filters, "segment_target")).toBe(true);
expect(doesSegmentReferenceSegment(filters, "segment_other")).toBe(false);
});
test("getReferencingSegments excludes the current segment and returns only matching segments", () => {
const segments = [
createSegment({ id: "segment_target" }),
createSegment({
id: "segment_ref",
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
createSegment({
id: "segment_other",
filters: [
{
id: "filter_2",
connector: null,
resource: {
id: "attribute_filter_1",
root: {
type: "attribute",
contactAttributeKey: "plan",
},
value: "enterprise",
qualifier: {
operator: "equals",
},
},
},
],
}),
] as TSegmentWithSurveyRefs[];
expect(getReferencingSegments(segments, "segment_target").map((segment) => segment.id)).toEqual([
"segment_ref",
]);
});
test("buildSegmentActivitySummary returns direct surveys grouped by status", () => {
const directSurveys = [
createSurvey({
id: "survey_direct",
name: "Direct Survey",
status: "inProgress",
}),
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
];
expect(buildSegmentActivitySummary(directSurveys, [])).toEqual({
activeSurveys: ["Direct Survey"],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary includes indirect surveys when there is no direct match", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_draft",
name: "Draft Survey",
status: "draft",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Draft Survey"],
});
});
test("buildSegmentActivitySummary prefers direct surveys over indirect duplicates", () => {
const directSurveys = [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
];
const indirectSurveyGroups = [
{
segmentId: "segment_ref",
segmentTitle: "Referenced Segment",
surveys: [
createSurvey({
id: "survey_shared",
name: "Shared Survey",
status: "inProgress",
}),
],
},
];
expect(buildSegmentActivitySummary(directSurveys, indirectSurveyGroups)).toEqual({
activeSurveys: ["Shared Survey"],
inactiveSurveys: [],
});
});
test("buildSegmentActivitySummary deduplicates indirect surveys referenced by multiple segments", () => {
const indirectSurveyGroups = [
{
segmentId: "segment_ref_1",
segmentTitle: "Referenced Segment 1",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
{
segmentId: "segment_ref_2",
segmentTitle: "Referenced Segment 2",
surveys: [
createSurvey({
id: "survey_indirect",
name: "Indirect Survey",
status: "paused",
}),
],
},
];
expect(buildSegmentActivitySummary([], indirectSurveyGroups)).toEqual({
activeSurveys: [],
inactiveSurveys: ["Indirect Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments merges direct and indirect surveys from segment table data", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
activeSurveys: [{ id: "survey_direct", name: "Direct Survey" }],
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
});
const segments = [
currentSegment,
createSegmentWithSurveyNames({
id: "segment_ref",
title: "Referenced Segment",
activeSurveys: [{ id: "survey_indirect", name: "Indirect Survey" }],
inactiveSurveys: [{ id: "survey_paused", name: "Paused Survey" }],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsIn",
},
},
},
],
}),
];
expect(buildSegmentActivitySummaryFromSegments(currentSegment, segments)).toEqual({
activeSurveys: ["Direct Survey", "Indirect Survey"],
inactiveSurveys: ["Paused Survey"],
});
});
test("buildSegmentActivitySummaryFromSegments includes indirect usage from private survey segments", () => {
const currentSegment = createSegmentWithSurveyNames({
id: "segment_target",
});
const privateReferencingSegment = createSegmentWithSurveyNames({
id: "segment_private_ref",
title: "Private Survey Segment",
isPrivate: true,
activeSurveys: [{ id: "survey_private", name: "Indirect Private Survey" }],
filters: [
{
id: "filter_1",
connector: null,
resource: {
id: "segment_filter_1",
root: {
type: "segment",
segmentId: "segment_target",
},
value: "segment_target",
qualifier: {
operator: "userIsNotIn",
},
},
},
],
});
expect(
buildSegmentActivitySummaryFromSegments(currentSegment, [currentSegment, privateReferencingSegment])
).toEqual({
activeSurveys: ["Indirect Private Survey"],
inactiveSurveys: [],
});
});
});
@@ -1,99 +0,0 @@
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
type TSurveySummary = Pick<TSurvey, "id" | "name" | "status">;
type TReferencingSegmentSurveyGroup = {
segmentId: string;
segmentTitle: string;
surveys: TSurveySummary[];
};
export type TSegmentActivitySummary = {
activeSurveys: string[];
inactiveSurveys: string[];
};
export const doesSegmentReferenceSegment = (filters: TBaseFilters, targetSegmentId: string): boolean => {
for (const filter of filters) {
const { resource } = filter;
if (Array.isArray(resource)) {
if (doesSegmentReferenceSegment(resource, targetSegmentId)) {
return true;
}
continue;
}
if (resource.root.type === "segment" && resource.root.segmentId === targetSegmentId) {
return true;
}
}
return false;
};
export const getReferencingSegments = (
segments: TSegmentWithSurveyRefs[],
targetSegmentId: string
): TSegmentWithSurveyRefs[] =>
segments.filter(
(segment) =>
segment.id !== targetSegmentId && doesSegmentReferenceSegment(segment.filters, targetSegmentId)
);
export const buildSegmentActivitySummary = (
directSurveys: TSurveySummary[],
indirectSurveyGroups: TReferencingSegmentSurveyGroup[]
): TSegmentActivitySummary => {
const surveyMap = new Map<string, TSurveySummary>();
for (const survey of directSurveys) {
surveyMap.set(survey.id, survey);
}
for (const segment of indirectSurveyGroups) {
for (const survey of segment.surveys) {
if (!surveyMap.has(survey.id)) {
surveyMap.set(survey.id, survey);
}
}
}
const surveys = Array.from(surveyMap.values());
return {
activeSurveys: surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name),
inactiveSurveys: surveys
.filter((survey) => survey.status === "draft" || survey.status === "paused")
.map((survey) => survey.name),
};
};
export const buildSegmentActivitySummaryFromSegments = (
currentSegment: TSegmentWithSurveyRefs,
segments: TSegmentWithSurveyRefs[]
): TSegmentActivitySummary => {
const activeSurveyMap = new Map(currentSegment.activeSurveys.map((s) => [s.id, s.name]));
const inactiveSurveyMap = new Map(currentSegment.inactiveSurveys.map((s) => [s.id, s.name]));
const allDirectIds = new Set([...activeSurveyMap.keys(), ...inactiveSurveyMap.keys()]);
const referencingSegments = getReferencingSegments(segments, currentSegment.id);
for (const segment of referencingSegments) {
for (const survey of segment.activeSurveys) {
if (!allDirectIds.has(survey.id) && !activeSurveyMap.has(survey.id)) {
activeSurveyMap.set(survey.id, survey.name);
}
}
for (const survey of segment.inactiveSurveys) {
if (!allDirectIds.has(survey.id) && !inactiveSurveyMap.has(survey.id)) {
inactiveSurveyMap.set(survey.id, survey.name);
}
}
}
return {
activeSurveys: Array.from(activeSurveyMap.values()),
inactiveSurveys: Array.from(inactiveSurveyMap.values()),
};
};

Some files were not shown because too many files have changed in this diff Show More