mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 21:59:28 -05:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aba02cf62c | |||
| 5d166cae8b | |||
| 645f0ab0d1 | |||
| 389a7d9e7b | |||
| c4cf468c7e | |||
| cbc3e923e4 | |||
| a96ba8b1e7 | |||
| e830871361 | |||
| 998e5c0819 | |||
| 13a56b0237 | |||
| 0b5418a03a | |||
| 0d8a338965 | |||
| d3250736a9 | |||
| e6ee6a6b0d | |||
| c0b097f929 | |||
| 78d336f8c7 | |||
| 95a7a265b9 | |||
| 136e59da68 | |||
| eb0a87cf80 | |||
| 0dcb98ac29 | |||
| 540f7aaae7 | |||
| 2d4614a0bd | |||
| 8d0847bb9a | |||
| 6c871b5cd5 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-10
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+10
-3
@@ -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>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+2
-4
@@ -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}
|
||||
|
||||
+10
-9
@@ -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;
|
||||
})
|
||||
);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+6
-8
@@ -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>
|
||||
|
||||
+6
-7
@@ -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>
|
||||
|
||||
+6
-5
@@ -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>
|
||||
);
|
||||
|
||||
+6
-7
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}, {});
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "都道府県",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Состояние",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "状态",
|
||||
|
||||
@@ -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": "州/省",
|
||||
|
||||
+6
-1
@@ -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>
|
||||
|
||||
+6
-4
@@ -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>;
|
||||
}
|
||||
|
||||
+11
-1
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user