Compare commits

...

24 Commits

Author SHA1 Message Date
pandeymangg aba02cf62c Trigger Checks 2026-03-24 12:20:28 +05:30
pandeymangg 5d166cae8b fixes merge conflicts 2026-03-23 17:05:53 +05:30
Matti Nannt 645f0ab0d1 fix: resolve remaining dependabot alerts (#7561) 2026-03-23 09:59:01 +00:00
Johannes 389a7d9e7b feat: enhance segment activity summary and settings in segment modal (#7553)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-23 08:39:10 +00:00
Tiago c4cf468c7e fix: localize survey and app date rendering (#7473)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-23 07:23:07 +00:00
Johannes cbc3e923e4 fix: segment targeting "isNotIn" didnt work (#7550) 2026-03-23 05:22:19 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Aryan 8d0847bb9a Requested Changes 2026-03-17 21:45:53 +05:30
Aryan 6c871b5cd5 feat: enhance welcome card to support video uploads and display 2026-03-17 21:45:53 +05:30
185 changed files with 6466 additions and 2363 deletions
+1 -1
View File
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGO_API_KEY=your_api_key_here
+8
View File
@@ -52,6 +52,14 @@ 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.
@@ -6,6 +6,7 @@ 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";
@@ -49,7 +50,8 @@ export const EnterpriseLicenseStatus = ({
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false);
@@ -97,14 +99,7 @@ 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")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div>
@@ -132,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
year: "numeric",
month: "short",
day: "numeric",
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
);
// Save settings to localStorage when they change
@@ -8,10 +8,11 @@ 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 { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { formatDateTimeForDisplay } 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";
@@ -34,6 +35,7 @@ const getElementColumnsData = (
element: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -167,6 +169,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -218,6 +221,7 @@ const getElementColumnsData = (
survey={survey}
responseData={responseValue}
language={language}
locale={locale}
isExpanded={isExpanded}
showId={false}
/>
@@ -259,11 +263,14 @@ 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, t));
const elementColumns = elements.flatMap((element) =>
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt",
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
size: 200,
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
},
};
@@ -7,7 +7,6 @@ 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,13 +22,12 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
if (!survey) {
@@ -86,7 +84,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
locale={user.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 { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
return (
formattedDate ??
t("common.invalid_date_with_value", {
value,
})
);
};
return (
@@ -59,7 +60,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 md:text-base">
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
@@ -84,7 +85,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 text-slate-500 md:px-6">
<div className="px-4 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
@@ -2,21 +2,16 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
Select,
SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
@@ -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, WEBAPP_URL } from "@/lib/constants";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,11 +18,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -33,9 +34,6 @@ 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("./");
}
@@ -52,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
@@ -3,13 +3,14 @@ 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 { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -21,19 +22,17 @@ 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 } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [surveys, integrations, locale] = 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("./");
}
@@ -49,7 +48,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
@@ -3,6 +3,7 @@ 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,
@@ -11,7 +12,7 @@ import {
} from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -28,18 +29,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration] = await Promise.all([
const [surveys, notionIntegration, locale] = 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("./");
@@ -56,7 +57,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
locale={locale ?? DEFAULT_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 { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,15 +17,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
const [surveys, slackIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]);
const locale = await findMatchingLocale();
if (isReadOnly) {
return redirect("./");
}
@@ -41,7 +40,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL}
locale={locale}
locale={locale ?? DEFAULT_LOCALE}
/>
</div>
</PageContentWrapper>
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});
+349
View File
@@ -0,0 +1,349 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
+274
View File
@@ -0,0 +1,274 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* V3 API auth — session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}
+4
View File
@@ -0,0 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
@@ -0,0 +1,38 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
/**
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});
@@ -0,0 +1,159 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}
+357
View File
@@ -0,0 +1,357 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,18 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}
+49 -19
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
@@ -12,6 +13,18 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({
userEmail,
userName,
@@ -20,15 +33,14 @@ export const ChatwootWidget = ({
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
const $chatwoot = getChatwoot();
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
}, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
@@ -65,23 +89,19 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
if (getChatwoot()) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
const $chatwoot = getChatwoot();
if ($chatwoot) {
$chatwoot.reset();
}
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
scriptElement?.remove();
userSetRef.current = false;
customerStatusSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
}, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null;
};
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
@@ -421,6 +421,38 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
+11 -1
View File
@@ -38,6 +38,11 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
}
enum ApiV1RouteTypeEnum {
@@ -265,7 +270,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig } = params;
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -287,6 +292,11 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse();
}
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
});
describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,
@@ -22,6 +22,9 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))
+5 -3
View File
@@ -147,6 +147,7 @@ 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
@@ -228,7 +229,7 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0
@@ -355,6 +356,7 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -808,6 +810,7 @@ checksums:
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1342,7 +1345,6 @@ 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
@@ -1632,13 +1634,13 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
environments/surveys/edit/spam_protection_threshold_heading: 29f9a8b00c5bcbb43aedc48138a5cf9c
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/star: 0586c1c76e8a0367c0a7b93adf598cb7
environments/surveys/edit/starts_with: f6673c17475708313c6a0f245b561781
environments/surveys/edit/state: 118de561d4525b14f9bb29ac9e86161d
+13 -1
View File
@@ -1,7 +1,19 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
export const onRequestError = Sentry.captureRequestError;
export const onRequestError: Instrumentation.onRequestError = (...args) => {
const [error] = args;
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
// These are handled gracefully in the UI and don't need server-side Sentry reporting
if (error instanceof Error && isExpectedError(error)) {
return;
}
Sentry.captureRequestError(...args);
};
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
+28 -55
View File
@@ -1,62 +1,13 @@
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();
@@ -75,6 +26,18 @@ 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", () => {
@@ -83,6 +46,12 @@ 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", () => {
@@ -90,13 +59,17 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024");
});
});
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);
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)
);
});
});
+27 -120
View File
@@ -1,120 +1,33 @@
import { formatDistance, intlFormat } from "date-fns";
import { type Locale, formatDistance } 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";
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 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 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",
}
);
};
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
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",
}
);
};
/** 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 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) => {
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
const date = new Date(dateString);
return formatDistance(date, new Date(), {
addSuffix: true,
@@ -122,27 +35,21 @@ export const timeSince = (dateString: string, locale: TUserLocale) => {
});
};
export const timeSinceDate = (date: Date) => {
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDistance(date, new Date(), {
addSuffix: true,
locale: getLocaleForTimeSince(locale),
});
};
export const formatDate = (date: Date) => {
return intlFormat(date, {
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
return formatDateForDisplay(date, locale, {
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
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,85 @@
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;
}, {});
};
+43 -4
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from "vitest";
import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
import {
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => {
@@ -8,13 +15,45 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5);
});
test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
test("formatDateWithOrdinal formats a date using the provided locale", () => {
// 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));
// Test the function
expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
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("isValidDateString validates correct date strings", () => {
+44 -13
View File
@@ -1,7 +1,17 @@
const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const relevantDigits = day < 30 ? day % 20 : day % 30;
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
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",
};
// Helper function to calculate difference in days between two dates
@@ -10,23 +20,44 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
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 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 isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/;
if (!regex.test(value)) {
return false;
}
const date = new Date(value);
return date;
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());
};
export const getFormattedDateTimeString = (date: Date): string => {
+24 -10
View File
@@ -32,16 +32,17 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
vi.mock("@/lib/utils/datetime", () => ({
isValidDateString: vi.fn((value) => {
try {
return !isNaN(new Date(value as string).getTime());
} catch {
return false;
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"}`;
}
}),
formatDateWithOrdinal: vi.fn(() => {
return "January 1st, 2023";
if (value === "01-02-2023" && format === "M-d-y") {
return `legacy-${locale}-${format}`;
}
return null;
}),
}));
@@ -477,7 +478,20 @@ describe("recall utility functions", () => {
};
const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on January 1st, 2023");
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");
});
test("formats array values as comma-separated list", () => {
+11 -7
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 { formatDateWithOrdinal, isValidDateString } from "./datetime";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display";
export interface fallbacks {
[id: string]: string;
@@ -224,7 +224,9 @@ export const parseRecallInfo = (
text: string,
responseData?: TResponseData,
variables?: TResponseVariables,
withSlash: boolean = false
withSlash: boolean = false,
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
@@ -254,12 +256,14 @@ export const parseRecallInfo = (
value = responseData[recallItemId];
// Apply formatting for special value types
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(", ");
if (typeof value === "string") {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale);
if (formattedDate) {
value = formattedDate;
}
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
}
}
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktive Umfragen",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
"invalid_date_with_value": "Ungültiges Datum: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Auswählen",
"select_all": "Alles auswählen",
"select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
@@ -852,9 +854,16 @@
"created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach",
"six_points": "6 Punkte",
"smiley": "Smiley",
"spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.",
"spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.",
"spam_protection_threshold_heading": "Antwortschwelle",
"shrink_preview": "Vorschau verkleinern",
"star": "Stern",
"starts_with": "Fängt an mit",
"state": "Bundesland",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inactive surveys",
"integration": "integration",
"integrations": "Integrations",
"invalid_date": "Invalid date",
"invalid_date_with_value": "Invalid date: {value}",
"invalid_file_name": "Invalid file name, please rename your file and try again",
"invalid_file_type": "Invalid file type",
"invite": "Invite",
@@ -382,6 +383,7 @@
"select": "Select",
"select_all": "Select all",
"select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey",
"select_teams": "Select teams",
"selected": "Selected",
@@ -852,9 +854,16 @@
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Smiley",
"spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.",
"spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.",
"spam_protection_threshold_heading": "Response threshold",
"shrink_preview": "Shrink Preview",
"star": "Star",
"starts_with": "Starts with",
"state": "State",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Encuestas inactivas",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date": "Fecha no válida",
"invalid_date_with_value": "Fecha no válida: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Seleccionar",
"select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
@@ -852,9 +854,16 @@
"created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple",
"six_points": "6 puntos",
"smiley": "Emoticono",
"spam_protection_note": "La protección contra spam no funciona para encuestas mostradas con los SDK de iOS, React Native y Android. Romperá la encuesta.",
"spam_protection_threshold_description": "Establece un valor entre 0 y 1, las respuestas por debajo de este valor serán rechazadas.",
"spam_protection_threshold_heading": "Umbral de respuesta",
"shrink_preview": "Contraer vista previa",
"star": "Estrella",
"starts_with": "Comienza con",
"state": "Estado",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Sondages inactifs",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date": "Date invalide",
"invalid_date_with_value": "Date invalide: {value}",
"invalid_file_name": "Nom de fichier invalide, veuillez renommer votre fichier et réessayer",
"invalid_file_type": "Type de fichier invalide",
"invite": "Inviter",
@@ -382,6 +383,7 @@
"select": "Sélectionner",
"select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre",
"select_language": "Sélectionner la langue",
"select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
@@ -852,9 +854,16 @@
"created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !",
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple",
"six_points": "6 points",
"smiley": "Sourire",
"spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.",
"spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.",
"spam_protection_threshold_heading": "Seuil de réponse",
"shrink_preview": "Réduire l'aperçu",
"star": "Étoile",
"starts_with": "Commence par",
"state": "État",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktív kérdőívek",
"integration": "integráció",
"integrations": "Integrációk",
"invalid_date": "Érvénytelen dátum",
"invalid_date_with_value": "Érvénytelen dátum: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Kiválasztás",
"select_all": "Összes kiválasztása",
"select_filter": "Szűrő kiválasztása",
"select_language": "Nyelv kiválasztása",
"select_survey": "Kérdőív kiválasztása",
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
@@ -852,9 +854,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű",
"six_points": "6 pont",
"smiley": "Hangulatjel",
"spam_protection_note": "A szemét elleni védekezés nem működik az iOS, React Native és Android SDK-kkal megjelenített kérdőíveknél. El fogja rontani a kérdőívet.",
"spam_protection_threshold_description": "Állítsa az értéket 0 és 1 közé, az ezen érték alatt lévő válaszok elutasításra kerülnek.",
"spam_protection_threshold_heading": "Válasz küszöbszintje",
"shrink_preview": "Előnézet összecsukása",
"star": "Csillag",
"starts_with": "Ezzel kezdődik",
"state": "Állapot",
+13 -3
View File
@@ -174,6 +174,7 @@
"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} 名のメンバー}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "非アクティブなフォーム",
"integration": "連携",
"integrations": "連携",
"invalid_date": "無効な日付です",
"invalid_date_with_value": "無効な日付です: {value}",
"invalid_file_name": "ファイル名が無効です。ファイル名を変更して再試行してください",
"invalid_file_type": "無効なファイルタイプです",
"invite": "招待",
@@ -382,6 +383,7 @@
"select": "選択",
"select_all": "すべて選択",
"select_filter": "フィルターを選択",
"select_language": "言語を選択",
"select_survey": "フォームを選択",
"select_teams": "チームを選択",
"selected": "選択済み",
@@ -852,9 +854,16 @@
"created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
"endpoint_pinged": "成功!Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください",
"response_created": "回答作成",
@@ -1413,7 +1422,6 @@
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル",
"six_points": "6点",
"smiley": "スマイリー",
"spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。",
"spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。",
"spam_protection_threshold_heading": "回答のしきい値",
"shrink_preview": "プレビューを縮小",
"star": "星",
"starts_with": "で始まる",
"state": "都道府県",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inactieve enquêtes",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date": "Ongeldige datum",
"invalid_date_with_value": "Ongeldige datum: {value}",
"invalid_file_name": "Ongeldige bestandsnaam. Hernoem uw bestand en probeer het opnieuw",
"invalid_file_type": "Ongeldig bestandstype",
"invite": "Uitnodiging",
@@ -382,6 +383,7 @@
"select": "Selecteer",
"select_all": "Selecteer alles",
"select_filter": "Filter selecteren",
"select_language": "Selecteer taal",
"select_survey": "Selecteer Enquête",
"select_teams": "Selecteer teams",
"selected": "Gekozen",
@@ -852,9 +854,16 @@
"created_by_third_party": "Gemaakt door een derde partij",
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
"endpoint_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!",
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"no_triggers": "Geen triggers",
"please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"reverse_order_occasionally": "Volgorde af en toe omkeren",
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Toon onderzoek maximaal",
"show_survey_to_users": "Enquête tonen aan % van de gebruikers",
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
"shrink_preview": "Voorbeeld invouwen",
"simple": "Eenvoudig",
"six_points": "6 punten",
"smiley": "Smiley",
"spam_protection_note": "Spambeveiliging werkt niet voor enquêtes die worden weergegeven met de iOS-, React Native- en Android SDK's. Het zal de enquête breken.",
"spam_protection_threshold_description": "Stel een waarde in tussen 0 en 1, reacties onder deze waarde worden afgewezen.",
"spam_protection_threshold_heading": "Reactiedrempel",
"shrink_preview": "Voorbeeld invouwen",
"star": "Ster",
"starts_with": "Begint met",
"state": "Staat",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Pesquisas inativas",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times",
"selected": "Selecionado",
@@ -852,9 +854,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"no_triggers": "Nenhum Gatilho",
"please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Recolher prévia",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inquéritos inativos",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
"invalid_date_with_value": "Data inválida: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Selecionar",
"select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
@@ -852,9 +854,16 @@
"created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"no_triggers": "Sem Acionadores",
"please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
"simple": "Simples",
"six_points": "6 pontos",
"smiley": "Sorridente",
"spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.",
"spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.",
"spam_protection_threshold_heading": "Limite de resposta",
"shrink_preview": "Reduzir pré-visualização",
"star": "Estrela",
"starts_with": "Começa com",
"state": "Estado",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Sondaje inactive",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date": "Dată invalidă",
"invalid_date_with_value": "Dată invalidă: {value}",
"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ă",
@@ -382,6 +383,7 @@
"select": "Selectați",
"select_all": "Selectați toate",
"select_filter": "Selectați filtrul",
"select_language": "Selectează limba",
"select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele",
"selected": "Selectat",
@@ -852,9 +854,16 @@
"created_by_third_party": "Creat de o Parte Terță",
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
"endpoint_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
"learn_to_verify": "Află cum să verifici semnăturile webhook",
"no_triggers": "Fără declanșatori",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns",
"reverse_order_occasionally": "Inversare ordine ocazional",
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
"roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
"simple": "Simplu",
"six_points": "6 puncte",
"smiley": "Smiley",
"spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.",
"spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.",
"spam_protection_threshold_heading": "Pragul răspunsurilor",
"shrink_preview": "Restrânge previzualizarea",
"star": "Stea",
"starts_with": "Începe cu",
"state": "Stare",
+13 -3
View File
@@ -174,6 +174,7 @@
"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} участника}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Неактивные опросы",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date": "Неверная дата",
"invalid_date_with_value": "Неверная дата: {value}",
"invalid_file_name": "Недопустимое имя файла, переименуйте файл и попробуйте снова",
"invalid_file_type": "Недопустимый тип файла",
"invite": "Пригласить",
@@ -382,6 +383,7 @@
"select": "Выбрать",
"select_all": "Выбрать все",
"select_filter": "Выбрать фильтр",
"select_language": "Выберите язык",
"select_survey": "Выбрать опрос",
"select_teams": "Выбрать команды",
"selected": "Выбрано",
@@ -852,9 +854,16 @@
"created_by_third_party": "Создано сторонней организацией",
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
"endpoint_bad_gateway_error": "Ошибка шлюза (502): Ошибка прокси/шлюза, сервис недоступен",
"endpoint_gateway_timeout_error": "Тайм-аут шлюза (504): Тайм-аут шлюза, сервис недоступен",
"endpoint_internal_server_error": "Внутренняя ошибка сервера (500): Сервис столкнулся с непредвиденной ошибкой",
"endpoint_method_not_allowed_error": "Метод не разрешен (405): Конечная точка существует, но не принимает POST-запросы",
"endpoint_not_found_error": "Не найдено (404): Конечная точка не существует",
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"no_triggers": "Нет триггеров",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан",
@@ -1413,7 +1422,6 @@
"custom_hostname": "Пользовательский хостнейм",
"customize_survey_logo": "Настроить логотип опроса",
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"date_format": "Формат даты",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).",
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа",
"reverse_order_occasionally": "Иногда обращать порядок",
"reverse_order_occasionally_except_last": "Иногда обращать порядок кроме последнего",
"roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Показать опрос максимум",
"show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
"simple": "Простой",
"six_points": "6 баллов",
"smiley": "Смайлик",
"spam_protection_note": "Защита от спама не работает для опросов, отображаемых с помощью SDK iOS, React Native и Android. Это приведёт к сбою опроса.",
"spam_protection_threshold_description": "Установите значение от 0 до 1, ответы ниже этого значения будут отклонены.",
"spam_protection_threshold_heading": "Порог ответа",
"shrink_preview": "Свернуть предпросмотр",
"star": "Звезда",
"starts_with": "Начинается с",
"state": "Состояние",
+13 -3
View File
@@ -174,6 +174,7 @@
"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}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "Inaktiva enkäter",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date": "Ogiltigt datum",
"invalid_date_with_value": "Ogiltigt datum: {value}",
"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",
@@ -382,6 +383,7 @@
"select": "Välj",
"select_all": "Välj alla",
"select_filter": "Välj filter",
"select_language": "Välj språk",
"select_survey": "Välj enkät",
"select_teams": "Välj team",
"selected": "Vald",
@@ -852,9 +854,16 @@
"created_by_third_party": "Skapad av tredje part",
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
"endpoint_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!",
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"no_triggers": "Inga utlösare",
"please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat",
@@ -1413,7 +1422,6 @@
"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",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ",
"reverse_order_occasionally": "Vänd ordning ibland",
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
"roundness": "Rundhet",
"roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "Visa enkät maximalt",
"show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
"simple": "Enkel",
"six_points": "6 poäng",
"smiley": "Smiley",
"spam_protection_note": "Spamskydd fungerar inte för enkäter som visas med iOS, React Native och Android SDK:er. Det kommer att bryta enkäten.",
"spam_protection_threshold_description": "Ställ in värde mellan 0 och 1, svar under detta värde kommer att avvisas.",
"spam_protection_threshold_heading": "Svarströskel",
"shrink_preview": "Minimera förhandsgranskning",
"star": "Stjärna",
"starts_with": "Börjar med",
"state": "Delstat",
+13 -3
View File
@@ -174,6 +174,7 @@
"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} 位成员}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "不 活跃 调查",
"integration": "集成",
"integrations": "集成",
"invalid_date": "无效 日期",
"invalid_date_with_value": "无效 日期: {value}",
"invalid_file_name": "文件名无效,请重命名文件后重试",
"invalid_file_type": "无效 的 文件 类型",
"invite": "邀请",
@@ -382,6 +383,7 @@
"select": "选择",
"select_all": "选择 全部",
"select_filter": "选择过滤器",
"select_language": "选择语言",
"select_survey": "选择 调查",
"select_teams": "选择 团队",
"selected": "已选择",
@@ -852,9 +854,16 @@
"created_by_third_party": "由 第三方 创建",
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
"endpoint_bad_gateway_error": "错误网关 (502):代理/网关错误,服务不可达",
"endpoint_gateway_timeout_error": "网关超时 (504):网关超时,服务不可达",
"endpoint_internal_server_error": "内部服务器错误 (500):服务遇到了意外错误",
"endpoint_method_not_allowed_error": "方法不被允许 (405):该端点存在,但不接受 POST 请求",
"endpoint_not_found_error": "未找到 (404):该端点不存在",
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook",
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
"learn_to_verify": "了解如何验证 webhook 签名",
"no_triggers": "无触发器",
"please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应",
@@ -1413,7 +1422,6 @@
"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": "删除区块",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项",
"reverse_order_occasionally": "偶尔反转顺序",
"reverse_order_occasionally_except_last": "偶尔反转顺序(最后一项除外)",
"roundness": "圆度",
"roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
"shrink_preview": "收起预览",
"simple": "简单",
"six_points": "6 分",
"smiley": "笑脸",
"spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。",
"spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。",
"spam_protection_threshold_heading": "响应 阈值",
"shrink_preview": "收起预览",
"star": "星",
"starts_with": "以...开始",
"state": "状态",
+13 -3
View File
@@ -174,6 +174,7 @@
"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} 位成員}}",
@@ -255,7 +256,7 @@
"inactive_surveys": "停用中的問卷",
"integration": "整合",
"integrations": "整合",
"invalid_date": "無效日期",
"invalid_date_with_value": "無效日期: {value}",
"invalid_file_name": "檔案名稱無效,請重新命名檔案後再試一次",
"invalid_file_type": "無效的檔案類型",
"invite": "邀請",
@@ -382,6 +383,7 @@
"select": "選擇",
"select_all": "全選",
"select_filter": "選擇篩選器",
"select_language": "選擇語言",
"select_survey": "選擇問卷",
"select_teams": "選擇 團隊",
"selected": "已選取",
@@ -852,9 +854,16 @@
"created_by_third_party": "由第三方建立",
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
"endpoint_bad_gateway_error": "錯誤的閘道 (502):代理/閘道錯誤,服務無法連線",
"endpoint_gateway_timeout_error": "閘道逾時 (504):閘道逾時,服務無法連線",
"endpoint_internal_server_error": "內部伺服器錯誤 (500):服務遇到了未預期的錯誤",
"endpoint_method_not_allowed_error": "不允許的方法 (405):該端點存在,但不接受 POST 請求",
"endpoint_not_found_error": "找不到 (404):該端點不存在",
"endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook",
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
"learn_to_verify": "了解如何驗證 webhook 簽章",
"no_triggers": "無觸發條件",
"please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址",
"response_created": "已建立回應",
@@ -1413,7 +1422,6 @@
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
@@ -1677,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項",
"reverse_order_occasionally": "偶爾反轉順序",
"reverse_order_occasionally_except_last": "偶爾反轉順序(最後一項除外)",
"roundness": "圓角",
"roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1705,13 +1715,13 @@
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"shrink_preview": "收合預覽",
"simple": "簡單",
"six_points": "6 分",
"smiley": "表情符號",
"spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。",
"spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。",
"spam_protection_threshold_heading": "回應閾值",
"shrink_preview": "收合預覽",
"star": "星形",
"starts_with": "開頭為",
"state": "州/省",
@@ -1,4 +1,5 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -18,6 +19,7 @@ interface LanguageDropdownProps {
}
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
if (enabledLanguages.length <= 1) {
@@ -27,7 +29,10 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title="Select Language" aria-label="Select Language">
<Button
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -5,7 +5,9 @@ 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";
@@ -15,6 +17,7 @@ interface ElementSkipProps {
elements: TSurveyElement[];
isFirstElementAnswered?: boolean;
responseData: TResponseData;
locale: TUserLocale;
}
export const ElementSkip = ({
@@ -23,8 +26,10 @@ export const ElementSkip = ({
elements,
isFirstElementAnswered,
responseData,
locale,
}: ElementSkipProps) => {
const { t } = useTranslation();
const dateFormats = getSurveyDateFormatMap(elements);
return (
<div>
{skippedElements && (
@@ -81,7 +86,11 @@ export const ElementSkip = ({
},
"default"
),
responseData
responseData,
undefined,
false,
locale,
dateFormats
)
)}
</p>
@@ -120,7 +129,11 @@ export const ElementSkip = ({
},
"default"
),
responseData
responseData,
undefined,
false,
locale,
dateFormats
)
)}
</p>
@@ -3,11 +3,12 @@ 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 { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
@@ -21,6 +22,7 @@ interface RenderResponseProps {
element: TSurveyElement;
survey: TSurvey;
language: string | null;
locale: TUserLocale;
isExpanded?: boolean;
showId: boolean;
}
@@ -30,6 +32,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
element,
survey,
language,
locale,
isExpanded = true,
showId,
}) => {
@@ -63,9 +66,8 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyElementTypeEnum.Date:
if (typeof responseData === "string") {
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
const formattedDate =
formatStoredDateForDisplay(responseData, element.format, locale) ?? responseData;
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}
@@ -6,7 +6,9 @@ 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";
@@ -21,14 +23,17 @@ 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) => {
@@ -61,6 +66,7 @@ export const SingleResponseCardBody = ({
status={"welcomeCard"}
isFirstElementAnswered={isFirstElementAnswered}
responseData={response.data}
locale={locale}
/>
)}
<div className="space-y-6">
@@ -98,7 +104,9 @@ export const SingleResponseCardBody = ({
getLocalizedValue(question.headline, "default"),
response.data,
response.variables,
true
true,
locale,
dateFormats
)
)
)}
@@ -109,6 +117,7 @@ export const SingleResponseCardBody = ({
survey={survey}
responseData={response.data[question.id]}
language={response.language}
locale={locale}
showId={true}
/>
</div>
@@ -118,6 +127,7 @@ export const SingleResponseCardBody = ({
skippedElements={skipped}
elements={elements}
responseData={response.data}
locale={locale}
status={
response.finished ||
(skippedQuestions.length > 0 &&
@@ -137,7 +137,12 @@ export const SingleResponseCard = ({
locale={locale}
/>
<SingleResponseCardBody survey={survey} response={response} skippedQuestions={skippedQuestions} />
<SingleResponseCardBody
survey={survey}
response={response}
skippedQuestions={skippedQuestions}
locale={locale}
/>
<ResponseTagsWrapper
key={response.id}
@@ -84,7 +84,7 @@ describe("helpers", () => {
test("should allow request when rate limit check passes", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
@@ -127,7 +127,7 @@ describe("helpers", () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
});
@@ -138,7 +138,7 @@ describe("helpers", () => {
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
for (const identifier of identifiers) {
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
await expect(applyRateLimit(mockConfig, identifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
}
@@ -161,7 +161,7 @@ describe("helpers", () => {
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
await expect(applyIPRateLimit(mockConfig)).resolves.toEqual({ allowed: true });
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
+13 -5
View File
@@ -3,7 +3,7 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit";
import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
/**
* Get client identifier for rate limiting with IP hashing
@@ -31,12 +31,20 @@ export const getClientIdentifier = async (): Promise<string> => {
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
* @throws {Error} When rate limit is exceeded or rate limiting system fails
*/
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
export const applyRateLimit = async (
config: TRateLimitConfig,
identifier: string
): Promise<TRateLimitResponse> => {
const result = await checkRateLimit(config, identifier);
if (!result.ok || !result.data.allowed) {
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
throw new TooManyRequestsError(
"Maximum number of requests reached. Please try again later.",
result.ok ? result.data.retryAfter : undefined
);
}
return result.data;
};
/**
@@ -46,7 +54,7 @@ export const applyRateLimit = async (config: TRateLimitConfig, identifier: strin
* @param config - Rate limit configuration to apply
* @throws {Error} When rate limit is exceeded or IP hashing fails
*/
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateLimitResponse> => {
const identifier = await getClientIdentifier();
await applyRateLimit(config, identifier);
return await applyRateLimit(config, identifier);
};
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
});
test("should have all action configurations", () => {
@@ -127,7 +127,7 @@ describe("rateLimitConfigs", () => {
mockEval.mockResolvedValue([1, 1]);
const config = rateLimitConfigs.api.v1;
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
await expect(applyRateLimit(config, "api-key-123")).resolves.toEqual({ allowed: true });
});
test("should enforce limits correctly for each config type", async () => {
@@ -136,6 +136,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
@@ -11,6 +11,7 @@ export const rateLimitConfigs = {
api: {
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
},
@@ -82,6 +82,7 @@ export const checkRateLimit = async (
const response: TRateLimitResponse = {
allowed: isAllowed === 1,
retryAfter: isAllowed === 1 ? undefined : ttlSeconds,
};
// Log rate limit violations for security monitoring
@@ -13,6 +13,7 @@ export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
const ZRateLimitResponse = z.object({
allowed: z.boolean().describe("Whether the request is allowed"),
retryAfter: z.int().positive().optional().describe("Seconds until the current rate-limit window resets"),
});
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
@@ -13,6 +13,7 @@ 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";
@@ -77,14 +78,6 @@ 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;
@@ -168,7 +161,17 @@ export const PricingTable = ({
const existingSubscriptionId = organization.billing.stripe?.subscriptionId ?? null;
const canShowSubscriptionButton = hasBillingRights && !!organization.billing.stripeCustomerId;
const showPlanSelector = !isStripeSetupIncomplete && (!isTrialing || hasPaymentMethod);
const usageCycleLabel = `${formatDate(usageCycleStart, locale)} - ${formatDate(usageCycleEnd, locale)}`;
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 responsesUnlimitedCheck = organization.billing.limits.monthly.responses === null;
const projectsUnlimitedCheck = organization.billing.limits.projects === null;
const currentPlanLevel =
@@ -433,7 +436,15 @@ export const PricingTable = ({
<AlertDescription>
{t("environments.settings.billing.pending_plan_change_description")
.replace("{{plan}}", getCurrentCloudPlanLabel(pendingChange.targetPlan, t))
.replace("{{date}}", formatDate(new Date(pendingChange.effectiveAt), locale))}
.replace(
"{{date}}",
formatDateForDisplay(new Date(pendingChange.effectiveAt), locale, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
})
)}
</AlertDescription>
{hasBillingRights && (
<AlertButton onClick={() => void undoPendingChange()} loading={isPlanActionPending === "undo"}>
@@ -2,12 +2,12 @@ import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment";
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";
@@ -53,7 +53,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const locale = await findMatchingLocale();
const locale = user.locale ?? DEFAULT_LOCALE;
return (
<ActivityTimeline
@@ -1,5 +1,6 @@
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";
@@ -9,7 +10,8 @@ import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributesWithKeyInfo] = await Promise.all([
const [locale, contact, attributesWithKeyInfo] = await Promise.all([
getLocale(),
getContact(contactId),
getContactAttributesWithKeyInfo(contactId),
]);
@@ -43,7 +45,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
return <IdBadge id={attr.value} />;
}
return formatAttributeValue(attr.value, attr.dataType);
return formatAttributeValue(attr.value, attr.dataType, locale);
};
return (
@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts",
description:
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
"Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
requestBody: {
required: true,
description:
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
"The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
content: {
"application/json": {
schema: ZContactBulkUploadRequest,
@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description:
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
"Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description:
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
"The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
content: {
"application/json": {
schema: ZContactCreateRequest,
@@ -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,7 +61,15 @@ export const generateAttributeTableColumns = (
header: t("common.created_at"),
cell: ({ row }) => {
const createdAt = row.original.createdAt;
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
return (
<span>
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
);
},
};
@@ -78,7 +78,7 @@ export const AttributesTable = ({
// Generate columns
const columns = useMemo(() => {
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale);
}, [searchValue, isReadOnly, isExpanded]);
}, [searchValue, isReadOnly, isExpanded, locale, t]);
// Load saved settings from localStorage
useEffect(() => {
@@ -2,6 +2,7 @@
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";
@@ -12,6 +13,7 @@ export const generateContactTableColumns = (
searchValue: string,
data: TContactTableData[],
isReadOnly: boolean,
locale: TUserLocale,
t: TFunction
): ColumnDef<TContactTableData>[] => {
const userColumn: ColumnDef<TContactTableData> = {
@@ -75,7 +77,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);
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType, locale);
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
},
};
@@ -17,6 +17,7 @@ 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";
@@ -65,14 +66,15 @@ export const ContactsTable = ({
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const [rowSelection, setRowSelection] = useState({});
const router = useRouter();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [parent] = useAutoAnimate();
// Generate columns
const columns = useMemo(() => {
return generateContactTableColumns(searchValue, data, isReadOnly, t);
}, [searchValue, data, isReadOnly]);
return generateContactTableColumns(searchValue, data, isReadOnly, locale, t);
}, [searchValue, data, isReadOnly, locale, t]);
// Load saved settings from localStorage
useEffect(() => {
@@ -1,4 +1,5 @@
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.
@@ -27,12 +28,11 @@ export const formatAttributeValue = (
if (Number.isNaN(date.getTime())) {
return String(value);
}
// Use Intl.DateTimeFormat for locale-aware date formatting
return new Intl.DateTimeFormat(locale, {
return formatDateForDisplay(date, locale, {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
});
} catch {
// If date parsing fails, return the raw value
return String(value);
@@ -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, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import {
Dialog,
@@ -15,23 +15,63 @@ 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: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
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,
@@ -40,31 +80,25 @@ 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} />,
children: <SegmentActivityTab currentSegment={currentSegment} activitySummary={activitySummary} />,
},
{
title: t("common.settings"),
children: <SettingsTab />,
children: (
<SegmentSettingsTab
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
currentSegment={currentSegment}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
segments={segments}
setOpen={setOpen}
/>
),
},
];
@@ -1,19 +1,22 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { convertDateTimeStringShort } from "@/lib/time";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { TSegmentActivitySummary } from "./segment-activity-utils";
interface SegmentActivityTabProps {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
}
export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps) => {
const { t } = useTranslation();
export const SegmentActivityTab = ({ currentSegment, activitySummary }: SegmentActivityTabProps) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const { activeSurveys, inactiveSurveys } = currentSegment;
const { activeSurveys, inactiveSurveys } = activitySummary;
return (
<div className="grid grid-cols-3 pb-2">
@@ -22,20 +25,20 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<Label className="text-slate-500">{t("common.active_surveys")}</Label>
{!activeSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{activeSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{activeSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
<div>
<Label className="text-slate-500">{t("common.inactive_surveys")}</Label>
{!inactiveSurveys?.length && <p className="text-sm text-slate-900">-</p>}
{inactiveSurveys?.map((survey, index) => (
<p className="text-sm text-slate-900" key={index + survey}>
{survey}
</p>
{inactiveSurveys?.map((surveyName) => (
<div className="py-0.5" key={surveyName}>
<p className="text-sm text-slate-900">{surveyName}</p>
</div>
))}
</div>
</div>
@@ -43,13 +46,13 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(currentSegment.createdAt?.toString())}
{formatDateTimeForDisplay(currentSegment.createdAt, locale)}
</p>
</div>{" "}
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(currentSegment.updatedAt?.toString())}
{formatDateTimeForDisplay(currentSegment.updatedAt, locale)}
</p>
</div>
<div>
@@ -0,0 +1,340 @@
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: [],
});
});
});
@@ -0,0 +1,99 @@
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()),
};
};
@@ -6,7 +6,7 @@ import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import type { TBaseFilter, TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import type { TBaseFilter, TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
@@ -16,18 +16,21 @@ import { Button } from "@/modules/ui/components/button";
import { ConfirmDeleteSegmentModal } from "@/modules/ui/components/confirm-delete-segment-modal";
import { Input } from "@/modules/ui/components/input";
import { AddFilterModal } from "./add-filter-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
import { SegmentEditor } from "./segment-editor";
interface TSegmentSettingsTabProps {
activitySummary: TSegmentActivitySummary;
environmentId: string;
setOpen: (open: boolean) => void;
initialSegment: TSegmentWithSurveyNames;
initialSegment: TSegmentWithSurveyRefs;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isReadOnly: boolean;
}
export function SegmentSettings({
activitySummary,
environmentId,
initialSegment,
setOpen,
@@ -38,7 +41,7 @@ export function SegmentSettings({
const router = useRouter();
const { t } = useTranslation();
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
const [segment, setSegment] = useState<TSegmentWithSurveyNames>(initialSegment);
const [segment, setSegment] = useState<TSegmentWithSurveyRefs>(initialSegment);
const [isUpdatingSegment, setIsUpdatingSegment] = useState(false);
const [isDeletingSegment, setIsDeletingSegment] = useState(false);
@@ -260,9 +263,9 @@ export function SegmentSettings({
{isDeleteSegmentModalOpen ? (
<ConfirmDeleteSegmentModal
activitySummary={activitySummary}
onDelete={handleDeleteSegment}
open={isDeleteSegmentModalOpen}
segment={initialSegment}
setOpen={setIsDeleteSegmentModalOpen}
/>
) : null}
@@ -1,13 +1,17 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { TFunction } from "i18next";
import { UsersIcon } from "lucide-react";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWithSurveyNames>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyNames> = {
export const generateSegmentTableColumns = (
t: TFunction,
locale: string
): ColumnDef<TSegmentWithSurveyRefs>[] => {
const titleColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "title",
accessorKey: "title",
header: t("common.title"),
@@ -28,26 +32,28 @@ export const generateSegmentTableColumns = (t: TFunction): ColumnDef<TSegmentWit
},
};
const updatedAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const updatedAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: t("common.updated_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
return <span className="text-sm text-slate-900">{timeSinceDate(row.original.updatedAt, locale)}</span>;
},
};
const createdAtColumn: ColumnDef<TSegmentWithSurveyNames> = {
const createdAtColumn: ColumnDef<TSegmentWithSurveyRefs> = {
id: "createdAt",
accessorKey: "createdAt",
header: t("common.created_at"),
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
<span className="text-sm text-slate-900">
{formatDateForDisplay(row.original.createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
);
},
};
@@ -1,46 +0,0 @@
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment";
import { getSurveysBySegmentId } from "@/lib/survey/service";
import { SegmentTableDataRow } from "./segment-table-data-row";
type TSegmentTableDataRowProps = {
currentSegment: TSegment;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
};
export const SegmentTableDataRowContainer = async ({
currentSegment,
segments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const surveys = await getSurveysBySegmentId(currentSegment.id);
const activeSurveys = surveys?.length
? surveys.filter((survey) => survey.status === "inProgress").map((survey) => survey.name)
: [];
const inactiveSurveys = surveys?.length
? surveys.filter((survey) => ["draft", "paused"].includes(survey.status)).map((survey) => survey.name)
: [];
const filteredSegments = segments.filter((segment) => segment.id !== currentSegment.id);
return (
<SegmentTableDataRow
currentSegment={{
...currentSegment,
activeSurveys,
inactiveSurveys,
}}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
isReadOnly={isReadOnly}
/>
);
};
@@ -1,14 +1,18 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegment, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { timeSinceDate } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { EditSegmentModal } from "./edit-segment-modal";
import { TSegmentActivitySummary } from "./segment-activity-utils";
type TSegmentTableDataRowProps = {
currentSegment: TSegmentWithSurveyNames;
currentSegment: TSegmentWithSurveyRefs;
activitySummary: TSegmentActivitySummary;
segments: TSegment[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
@@ -17,13 +21,16 @@ type TSegmentTableDataRowProps = {
export const SegmentTableDataRow = ({
currentSegment,
activitySummary,
contactAttributeKeys,
segments,
isContactsEnabled,
isReadOnly,
}: TSegmentTableDataRowProps) => {
const { i18n } = useTranslation();
const { createdAt, environmentId, id, surveys, title, updatedAt, description } = currentSegment;
const [isEditSegmentModalOpen, setIsEditSegmentModalOpen] = useState(false);
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return (
<>
@@ -46,14 +53,16 @@ export const SegmentTableDataRow = ({
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{formatDistanceToNow(updatedAt, {
addSuffix: true,
}).replace("about", "")}
</div>
<div className="ph-no-capture text-slate-900">{timeSinceDate(updatedAt, locale)}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
<div className="ph-no-capture text-slate-900">
{formatDateForDisplay(createdAt, locale, {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
</button>
@@ -62,6 +71,7 @@ export const SegmentTableDataRow = ({
open={isEditSegmentModalOpen}
setOpen={setIsEditSegmentModalOpen}
currentSegment={currentSegment}
activitySummary={activitySummary}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -4,13 +4,15 @@ import { Header, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { EditSegmentModal } from "./edit-segment-modal";
import { buildSegmentActivitySummaryFromSegments } from "./segment-activity-utils";
import { generateSegmentTableColumns } from "./segment-table-columns";
interface SegmentTableUpdatedProps {
segments: TSegmentWithSurveyNames[];
segments: TSegmentWithSurveyRefs[];
allSegments: TSegmentWithSurveyRefs[];
contactAttributeKeys: TContactAttributeKey[];
isContactsEnabled: boolean;
isReadOnly: boolean;
@@ -18,16 +20,18 @@ interface SegmentTableUpdatedProps {
export function SegmentTable({
segments,
allSegments,
contactAttributeKeys,
isContactsEnabled,
isReadOnly,
}: SegmentTableUpdatedProps) {
const { t } = useTranslation();
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyNames | null>(null);
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const [editingSegment, setEditingSegment] = useState<TSegmentWithSurveyRefs | null>(null);
const columns = useMemo(() => {
return generateSegmentTableColumns(t);
}, []);
return generateSegmentTableColumns(t, locale);
}, [locale, t]);
const table = useReactTable({
data: segments,
@@ -35,7 +39,7 @@ export function SegmentTable({
getCoreRowModel: getCoreRowModel(),
});
const getHeader = (header: Header<TSegmentWithSurveyNames, unknown>) => {
const getHeader = (header: Header<TSegmentWithSurveyRefs, unknown>) => {
if (header.isPlaceholder) {
return null;
}
@@ -136,6 +140,7 @@ export function SegmentTable({
open={!!editingSegment}
setOpen={(open) => !open && setEditingSegment(null)}
currentSegment={editingSegment}
activitySummary={buildSegmentActivitySummaryFromSegments(editingSegment, allSegments)}
contactAttributeKeys={contactAttributeKeys}
segments={segments}
isContactsEnabled={isContactsEnabled}
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
@@ -270,7 +270,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -336,7 +336,7 @@ describe("segmentFilterToPrismaQuery", () => {
// Mock getSegment to return null for the non-existent segment
vi.mocked(getSegment).mockResolvedValueOnce(mockSegment);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs);
const result = await segmentFilterToPrismaQuery(mockSegmentId, filters, mockEnvironmentId);
@@ -426,7 +426,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the getSegment function to return a segment with filters
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -490,7 +490,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle circular references in segment filters", async () => {
// Mock getSegment to simulate a circular reference
const circularSegment: TSegmentWithSurveyNames = {
const circularSegment: TSegmentWithSurveyRefs = {
id: mockSegmentId, // Same ID creates the circular reference
filters: [
{
@@ -550,7 +550,7 @@ describe("segmentFilterToPrismaQuery", () => {
test("handle missing segments in segment filters", async () => {
const nestedSegmentId = "segment-missing-123";
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(null as unknown as TSegmentWithSurveyRefs);
const filters: TBaseFilters = [
{
@@ -599,7 +599,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Mock the nested segment
const mockNestedSegment: TSegmentWithSurveyNames = {
const mockNestedSegment: TSegmentWithSurveyRefs = {
id: nestedSegmentId,
filters: nestedFilters,
environmentId: mockEnvironmentId,
@@ -890,7 +890,7 @@ describe("segmentFilterToPrismaQuery", () => {
];
// Set up the mocks
const mockCircularSegment: TSegmentWithSurveyNames = {
const mockCircularSegment: TSegmentWithSurveyRefs = {
id: circularSegmentId,
filters: circularFilters,
environmentId: mockEnvironmentId,
@@ -904,7 +904,7 @@ describe("segmentFilterToPrismaQuery", () => {
inactiveSurveys: [],
};
const mockSecondSegment: TSegmentWithSurveyNames = {
const mockSecondSegment: TSegmentWithSurveyRefs = {
id: secondSegmentId,
filters: secondFilters,
environmentId: mockEnvironmentId,
@@ -922,7 +922,7 @@ describe("segmentFilterToPrismaQuery", () => {
vi.mocked(getSegment)
.mockResolvedValueOnce(mockCircularSegment) // First call for circularSegmentId
.mockResolvedValueOnce(mockSecondSegment) // Third call for secondSegmentId
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyNames); // Fourth call for non-existent-segment
.mockResolvedValueOnce(null as unknown as TSegmentWithSurveyRefs); // Fourth call for non-existent-segment
// Complex filters with mixed error conditions
const filters: TBaseFilters = [
@@ -361,7 +361,7 @@ const buildSegmentFilterWhereClause = async (
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
const { root, qualifier } = filter;
const { segmentId } = root;
if (segmentPath.has(segmentId)) {
@@ -382,7 +382,22 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, environmentId, deviceType);
const nestedWhereClause = await processFilters(segment.filters, newPath, environmentId, deviceType);
const hasNestedConditions = Object.keys(nestedWhereClause).length > 0;
if (qualifier.operator === "userIsIn") {
return nestedWhereClause;
}
if (qualifier.operator === "userIsNotIn") {
if (!hasNestedConditions) {
return { id: "__SEGMENT_FILTER_NO_MATCH__" };
}
return { NOT: nestedWhereClause };
}
return {};
};
/**
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TBaseFilters, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { TBaseFilters, TSegmentWithSurveyRefs } from "@formbricks/types/segment";
import { checkForRecursiveSegmentFilter } from "@/modules/ee/contacts/segments/lib/helper";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
@@ -77,7 +77,7 @@ describe("checkForRecursiveSegmentFilter", () => {
],
};
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyNames);
vi.mocked(getSegment).mockResolvedValue(referencedSegment as unknown as TSegmentWithSurveyRefs);
// Act & Assert
// The function should complete without throwing an error
@@ -8,7 +8,7 @@ import {
TEvaluateSegmentUserData,
TSegmentCreateInput,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
} from "@formbricks/types/segment";
import { getSurvey } from "@/lib/survey/service";
import { validateInputs } from "@/lib/utils/validate";
@@ -79,10 +79,10 @@ const mockSegmentPrisma = {
surveys: [{ id: surveyId, name: "Test Survey", status: "inProgress" }],
};
const mockSegment: TSegmentWithSurveyNames = {
const mockSegment: TSegmentWithSurveyRefs = {
...mockSegmentPrisma,
surveys: [surveyId],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -287,7 +287,7 @@ describe("Segment Service Tests", () => {
...mockSegment,
id: clonedSegmentId,
title: "Copy of Test Segment (1)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -327,7 +327,7 @@ describe("Segment Service Tests", () => {
const clonedSegment2 = {
...clonedSegment,
title: "Copy of Test Segment (2)",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -415,7 +415,7 @@ describe("Segment Service Tests", () => {
title: surveyId,
isPrivate: true,
filters: [],
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
@@ -487,7 +487,7 @@ describe("Segment Service Tests", () => {
const updatedSegment = {
...mockSegment,
title: "Updated Segment",
activeSurveys: ["Test Survey"],
activeSurveys: [{ id: surveyId, name: "Test Survey" }],
inactiveSurveys: [],
};
const updateData: TSegmentUpdateInput = { title: "Updated Segment" };
@@ -531,7 +531,7 @@ describe("Segment Service Tests", () => {
...updatedSegment,
surveys: [newSurveyId],
activeSurveys: [],
inactiveSurveys: ["New Survey"],
inactiveSurveys: [{ id: newSurveyId, name: "New Survey" }],
};
vi.mocked(prisma.segment.update).mockResolvedValue(updatedSegmentPrismaWithSurvey);
@@ -25,7 +25,7 @@ import {
TSegmentPersonFilter,
TSegmentSegmentFilter,
TSegmentUpdateInput,
TSegmentWithSurveyNames,
TSegmentWithSurveyRefs,
ZRelativeDateValue,
ZSegmentCreateInput,
ZSegmentFilters,
@@ -66,14 +66,14 @@ export const selectSegment = {
},
} satisfies Prisma.SegmentSelect;
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyNames => {
export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurveyRefs => {
const activeSurveys = segment.surveys
.filter((survey) => survey.status === "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
const inactiveSurveys = segment.surveys
.filter((survey) => survey.status !== "inProgress")
.map((survey) => survey.name);
.map((survey) => ({ id: survey.id, name: survey.name }));
return {
...segment,
@@ -83,7 +83,7 @@ export const transformPrismaSegment = (segment: PrismaSegment): TSegmentWithSurv
};
};
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyNames> => {
export const getSegment = reactCache(async (segmentId: string): Promise<TSegmentWithSurveyRefs> => {
validateInputs([segmentId, ZId]);
try {
const segment = await prisma.segment.findUnique({
@@ -107,7 +107,7 @@ export const getSegment = reactCache(async (segmentId: string): Promise<TSegment
}
});
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyNames[]> => {
export const getSegments = reactCache(async (environmentId: string): Promise<TSegmentWithSurveyRefs[]> => {
validateInputs([environmentId, ZId]);
try {
const segments = await prisma.segment.findMany({
@@ -47,6 +47,7 @@ export const SegmentsPage = async ({
upgradePromptTitle={t("environments.segments.unlock_segments_title")}
upgradePromptDescription={t("environments.segments.unlock_segments_description")}>
<SegmentTable
allSegments={segments}
segments={filteredSegments}
contactAttributeKeys={contactAttributeKeys}
isContactsEnabled={isContactsEnabled}
@@ -75,6 +75,7 @@ export async function PreviewEmailTemplate({
survey,
surveyUrl,
styling,
locale,
t,
}: PreviewEmailTemplateProps): Promise<React.JSX.Element> {
const url = `${surveyUrl}?preview=true`;
@@ -85,8 +86,20 @@ export async function PreviewEmailTemplate({
const questions = getElementsFromBlocks(survey.blocks);
const firstQuestion = questions[0];
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const headline = parseRecallInfo(
getLocalizedValue(firstQuestion.headline, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const subheader = parseRecallInfo(
getLocalizedValue(firstQuestion.subheader, defaultLanguageCode),
undefined,
undefined,
false,
locale
);
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
switch (firstQuestion.type) {
@@ -85,9 +85,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${
errMessage.length < 250
? `${t("common.error")}: ${errMessage}`
: t("environments.integrations.webhooks.please_check_console")
errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")
}`,
{ className: errMessage.length < 250 ? "break-all" : "" }
);
@@ -5,6 +5,7 @@ import { WebhookIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { type TUserLocale } from "@formbricks/types/user";
import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab";
import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab";
import {
@@ -25,13 +26,14 @@ interface WebhookModalProps {
}
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
const tabs = [
{
title: t("common.overview"),
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} />,
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} locale={locale} />,
},
{
title: t("common.settings"),
@@ -4,12 +4,14 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { convertDateTimeStringShort } from "@/lib/time";
import { type TUserLocale } from "@formbricks/types/user";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}
const getSurveyNamesForWebhook = (webhook: Webhook, allSurveys: TSurvey[]): string[] => {
@@ -36,7 +38,7 @@ const convertTriggerIdToName = (triggerId: string, t: TFunction): string => {
}
};
export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
export const WebhookOverviewTab = ({ webhook, surveys, locale }: ActivityTabProps) => {
const { t } = useTranslation();
return (
<div className="grid grid-cols-3 pb-2">
@@ -81,15 +83,11 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.created_at")}</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.createdAt?.toString())}
</p>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.createdAt, locale)}</p>
</div>
<div>
<Label className="text-xs font-normal text-slate-500">{t("common.updated_at")}</Label>
<p className="text-xs text-slate-700">
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
</p>
<p className="text-xs text-slate-700">{formatDateTimeForDisplay(webhook.updatedAt, locale)}</p>
</div>
</div>
</div>
@@ -4,26 +4,37 @@ import { Webhook } from "@prisma/client";
import { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
let surveyNames: string[];
if (webhook.surveyIds.length === 0) {
const allSurveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
surveyNames = allSurveys.map((survey) => survey.name);
} else {
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
const survey = allSurveys.find((survey) => survey.id === surveyId);
return survey ? survey.name : "";
});
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
surveyNames = webhook.surveyIds
.map((surveyId) => {
const survey = allSurveys.find((s) => s.id === surveyId);
return survey ? survey.name : "";
})
.filter(Boolean);
}
if (surveyNames.length === 0) {
return <p className="text-slate-400">-</p>;
}
return (
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
{surveyNames.join(", ")}
</p>
);
};
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
if (webhook.triggers.length === 0) {
return <p className="text-slate-400">No Triggers</p>;
return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
} else {
let cleanedTriggers = webhook.triggers.map((trigger) => {
if (trigger === "responseCreated") {
@@ -55,16 +66,10 @@ const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
}
};
export const WebhookRowData = ({
webhook,
surveys,
locale,
}: {
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}) => {
const { t } = useTranslation();
export const WebhookRowData = ({ webhook, surveys }: { webhook: Webhook; surveys: TSurvey[] }) => {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
return (
<div className="mt-2 grid h-auto grid-cols-12 content-center rounded-lg py-2 hover:bg-slate-100">
<div className="col-span-3 flex items-center truncate pl-6 text-sm">
@@ -91,7 +96,7 @@ export const WebhookRowData = ({
{renderSelectedTriggersText(webhook, t)}
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(webhook.createdAt.toString(), locale)}
{timeSince(webhook.updatedAt.toString(), locale)}
</div>
<div className="text-center"></div>
</div>
@@ -82,7 +82,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
setHittingEndpoint(false);
const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? `${t("common.error")}: ${errMessage}` : t("environments.integrations.webhooks.please_check_console")}`,
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")}`,
{ className: errMessage.length < 250 ? "break-all" : "" }
);
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage);
@@ -300,7 +300,9 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
)}
<Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/api/management/webhooks" target="_blank">
<Link
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks"
target="_blank">
{t("common.read_docs")}
</Link>
</Button>
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { testEndpoint } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
create: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
generateWebhookSecret: vi.fn(() => "generated-secret"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn(async () => undefined),
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: vi.fn(async () => (key: string) => key),
}));
vi.mock("@/modules/integrations/webhooks/lib/utils", () => ({
isDiscordWebhook: vi.fn(() => false),
}));
vi.mock("uuid", () => ({
v7: vi.fn(() => "webhook-message-id"),
}));
describe("testEndpoint", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
vi.mocked(isDiscordWebhook).mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test.each([
[500, "environments.integrations.webhooks.endpoint_internal_server_error"],
[404, "environments.integrations.webhooks.endpoint_not_found_error"],
[405, "environments.integrations.webhooks.endpoint_method_not_allowed_error"],
[502, "environments.integrations.webhooks.endpoint_bad_gateway_error"],
[503, "environments.integrations.webhooks.endpoint_service_unavailable_error"],
[504, "environments.integrations.webhooks.endpoint_gateway_timeout_error"],
])("throws a translated InvalidInputError for blocked status %s", async (statusCode, messageKey) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: statusCode,
}))
);
await expect(testEndpoint("https://example.com/webhook", "secret")).rejects.toThrow(
new InvalidInputError(messageKey)
);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(generateStandardWebhookSignature).toHaveBeenCalled();
expect(getTranslate).toHaveBeenCalled();
});
test("allows non-blocked non-2xx statuses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 418,
}))
);
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
expect(getTranslate).not.toHaveBeenCalled();
});
test("rejects Discord webhooks before sending the request", async () => {
vi.mocked(isDiscordWebhook).mockReturnValue(true);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://discord.com/api/webhooks/123")).rejects.toThrow(
"Discord webhooks are currently not supported."
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("throws a timeout error when the request is aborted", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn((_url, init) => {
const signal = init?.signal as AbortSignal;
return new Promise((_, reject) => {
signal.addEventListener("abort", () => {
const abortError = new Error("The operation was aborted");
abortError.name = "AbortError";
reject(abortError);
});
});
})
);
const requestPromise = testEndpoint("https://example.com/webhook");
const assertion = expect(requestPromise).rejects.toThrow("Request timed out after 5 seconds");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
test("wraps unexpected fetch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Promise.reject(new Error("socket hang up")))
);
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
"Error while fetching the URL: socket hang up"
);
});
});

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