mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-29 09:31:06 -05:00
Compare commits
53 Commits
fix-manage
...
chore/depr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d8fa1878a | ||
|
|
d202b9263f | ||
|
|
71cca557fc | ||
|
|
1500b6f7f3 | ||
|
|
2c9fbf83e4 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
81272b96e1 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 | ||
|
|
e7ca66ed77 | ||
|
|
2b49dbecd3 | ||
|
|
6da4c6f352 | ||
|
|
659b240fca | ||
|
|
19c0b1d14d | ||
|
|
b4472f48e9 | ||
|
|
d197271771 | ||
|
|
37f652c70e | ||
|
|
645f0ab0d1 | ||
|
|
389a7d9e7b | ||
|
|
c4cf468c7e | ||
|
|
cbc3e923e4 | ||
|
|
a96ba8b1e7 | ||
|
|
e830871361 | ||
|
|
998e5c0819 | ||
|
|
13a56b0237 | ||
|
|
0b5418a03a | ||
|
|
0d8a338965 | ||
|
|
d3250736a9 | ||
|
|
e6ee6a6b0d | ||
|
|
c0b097f929 | ||
|
|
78d336f8c7 | ||
|
|
95a7a265b9 | ||
|
|
136e59da68 | ||
|
|
eb0a87cf80 | ||
|
|
0dcb98ac29 | ||
|
|
540f7aaae7 | ||
|
|
2d4614a0bd | ||
|
|
633bf18204 | ||
|
|
9a6cbd05b6 | ||
|
|
94b0248075 | ||
|
|
082de1042d | ||
|
|
8c19587baa | ||
|
|
433750d3fe | ||
|
|
61befd5ffd | ||
|
|
1e7817fb69 | ||
|
|
f250bc7e88 | ||
|
|
c7faa29437 | ||
|
|
a51a006c26 | ||
|
|
ce96cb0b89 | ||
|
|
fb265d9dba | ||
|
|
e4c155b501 |
12
.env.example
12
.env.example
@@ -38,6 +38,15 @@ LOG_LEVEL=info
|
||||
|
||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||
|
||||
#################
|
||||
# HUB (DEV) #
|
||||
#################
|
||||
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
################
|
||||
@@ -150,7 +159,6 @@ NOTION_OAUTH_CLIENT_ID=
|
||||
NOTION_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Stripe Billing Variables
|
||||
STRIPE_PRICING_TABLE_ID=
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
@@ -232,4 +240,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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, organizationId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -28,7 +29,7 @@ const OnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
@@ -45,7 +46,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.team"), null);
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
@@ -17,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -46,7 +50,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
@@ -42,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -20,15 +21,15 @@ const AccountSettingsLayout = async (props: {
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
@@ -146,18 +147,18 @@ const Page = async (props: {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
const autoDisableNotificationType = searchParams["type"];
|
||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
if (!memberships) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||
}
|
||||
|
||||
if (user?.notificationSettings) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const user = session?.user ? await getUser(session.user.id) : null;
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -25,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||
|
||||
type TFeatureDefinition = {
|
||||
key: TPublicLicenseFeatureKey;
|
||||
labelKey: string;
|
||||
docsUrl: string;
|
||||
};
|
||||
|
||||
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
return [
|
||||
{
|
||||
key: "contacts",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_projects"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||
},
|
||||
{
|
||||
key: "whitelabel",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
},
|
||||
{
|
||||
key: "removeBranding",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||
},
|
||||
{
|
||||
key: "twoFactorAuth",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||
},
|
||||
{
|
||||
key: "sso",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||
},
|
||||
{
|
||||
key: "spamProtection",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||
},
|
||||
{
|
||||
key: "accessControl",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||
},
|
||||
{
|
||||
key: "quotas",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
interface EnterpriseLicenseFeaturesTableProps {
|
||||
features: TEnterpriseLicenseFeatures;
|
||||
}
|
||||
|
||||
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||
noPadding>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||
<TableHead>{t("common.documentation")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getFeatureDefinitions(t).map((feature) => {
|
||||
const value = features[feature.key];
|
||||
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
||||
let displayValue: number | string = "—";
|
||||
|
||||
if (typeof value === "number") {
|
||||
displayValue = value;
|
||||
} else if (value === null) {
|
||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={feature.key} className="hover:bg-white">
|
||||
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
type={isEnabled ? "success" : "gray"}
|
||||
size="normal"
|
||||
text={
|
||||
isEnabled
|
||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={feature.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -15,6 +16,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
|
||||
|
||||
interface EnterpriseLicenseStatusProps {
|
||||
status: TLicenseStatus;
|
||||
lastChecked: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
@@ -44,10 +46,12 @@ const getBadgeConfig = (
|
||||
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
lastChecked,
|
||||
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);
|
||||
|
||||
@@ -92,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -118,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",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
/>
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
<>
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
lastChecked={licenseState.lastChecked}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -17,15 +18,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, 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
|
||||
@@ -300,7 +300,6 @@ export const ResponseTable = ({
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -5,9 +6,8 @@ import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getTagsByProjectId } 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,34 +23,35 @@ 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 projectId = environment.projectId;
|
||||
|
||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getTagsByProjectId(projectId),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
const segments = isContactsEnabled ? await getSegments(projectId) : [];
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||
@@ -86,7 +87,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}
|
||||
|
||||
@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const t = await getTranslate();
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Workspace not found");
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
|
||||
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import {
|
||||
getElementSummary,
|
||||
getResponsesForSummary,
|
||||
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||
evaluateLogic: vi.fn(),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||
vi.mocked(performActions).mockReturnValue({
|
||||
jumpTarget: undefined,
|
||||
requiredElementIds: [],
|
||||
calculations: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 },
|
||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
||||
finished: false,
|
||||
}, // Dropped at q2
|
||||
{
|
||||
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
|
||||
);
|
||||
|
||||
expect(dropOff.length).toBe(2);
|
||||
// Q1
|
||||
// Q1: welcome card disabled so impressions = displayCount
|
||||
expect(dropOff[0].elementId).toBe("q1");
|
||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||
expect(dropOff[0].impressions).toBe(displayCount);
|
||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||
expect(dropOff[0].ttc).toBe(10);
|
||||
|
||||
// Q2
|
||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
||||
expect(dropOff[1].elementId).toBe("q2");
|
||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(10);
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
});
|
||||
|
||||
test("handles logic jumps", () => {
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
||||
const surveyWithWelcome: TSurvey = {
|
||||
...surveyWithBlocks,
|
||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||
};
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
||||
finished: false,
|
||||
},
|
||||
] as any;
|
||||
const displayCount = 1;
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithWelcome,
|
||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
||||
responses,
|
||||
displayCount
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
||||
expect(dropOff[1].dropOffCount).toBe(0);
|
||||
});
|
||||
|
||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
||||
const surveyWithLogic: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
logic: [
|
||||
{
|
||||
id: "logic1",
|
||||
conditions: {
|
||||
id: "condition1",
|
||||
connector: "and" as const,
|
||||
conditions: [
|
||||
{
|
||||
id: "c1",
|
||||
leftOperand: {
|
||||
type: "element" as const,
|
||||
value: "q2",
|
||||
},
|
||||
operator: "equals" as const,
|
||||
rightOperand: {
|
||||
type: "static" as const,
|
||||
value: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "action1",
|
||||
objective: "jumpToBlock" as const,
|
||||
target: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block3",
|
||||
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b" },
|
||||
data: { q1: "a", q2: "b", q4: "d" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10, q2: 10 },
|
||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
||||
finished: false,
|
||||
}, // Jumps from q2 to q4, drops at q4
|
||||
},
|
||||
];
|
||||
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||
// Simulate logic on q2 triggering
|
||||
return data.q2 === "b";
|
||||
});
|
||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||
}
|
||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||
});
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithLogic,
|
||||
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
|
||||
1
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // q1
|
||||
expect(dropOff[1].impressions).toBe(1); // q2
|
||||
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
TResponseData,
|
||||
TResponseFilterCriteria,
|
||||
TResponseTtc,
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
@@ -37,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
@@ -93,63 +91,13 @@ export const getSurveySummaryMeta = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateLogicAndGetNextElementId = (
|
||||
localSurvey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentElementIndex: number,
|
||||
currElementTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextElementId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredElementIds.length > 0) {
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next element
|
||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextElementId, updatedSurvey, updatedVariables };
|
||||
// Determine whether a response interacted with a given element.
|
||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
||||
// when branching logic skips elements or when partial response data is insufficient
|
||||
// to evaluate conditions correctly.
|
||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
@@ -170,16 +118,8 @@ export const getSurveySummaryDropOff = (
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.id] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
// Calculate total time-to-completion per element
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
@@ -187,51 +127,21 @@ export const getSurveySummaryDropOff = (
|
||||
}
|
||||
});
|
||||
|
||||
let localSurvey = structuredClone(survey);
|
||||
let localResponseData: TResponseData = { ...response.data };
|
||||
let localVariables: TResponseVariables = {
|
||||
...surveyVariablesData,
|
||||
};
|
||||
// Count impressions based on actual interaction data (ttc + response data)
|
||||
// instead of replaying survey logic which is unreliable with branching
|
||||
let lastSeenIdx = -1;
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < elements.length) {
|
||||
const currQues = elements[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// element is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
break;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (wasElementSeen(response, element.id)) {
|
||||
impressionsArr[i]++;
|
||||
lastSeenIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||
localSurvey,
|
||||
elements,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
currQues,
|
||||
response.language
|
||||
);
|
||||
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextElementId) {
|
||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||
if (!response.data[nextElementId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
currQuesIdx = nextQuesIdx;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
// Attribute drop-off to the last element the respondent interacted with
|
||||
if (!response.finished && lastSeenIdx >= 0) {
|
||||
dropOffArr[lastSeenIdx]++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,6 +150,8 @@ export const getSurveySummaryDropOff = (
|
||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||
});
|
||||
|
||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
||||
// because every survey display is an impression of the first element
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
@@ -251,7 +163,7 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
impressionsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
||||
}
|
||||
|
||||
for (let i = 1; i < elements.length; i++) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -23,6 +24,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const surveyId = params.surveyId;
|
||||
|
||||
if (!surveyId) {
|
||||
@@ -32,25 +35,25 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
const segments = isContactsEnabled ? await getSegments(projectId) : [];
|
||||
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
||||
}
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
|
||||
@@ -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 { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByProjectId } 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({
|
||||
@@ -89,76 +84,13 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
const projectId = survey.projectId!;
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
|
||||
getTagsByEnvironmentId(survey.environmentId),
|
||||
getTagsByProjectId(projectId),
|
||||
getResponseFilteringValues(parsedInput.surveyId),
|
||||
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
|
||||
]);
|
||||
|
||||
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,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -54,6 +55,25 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SurveyContextWrapper } from "./context/survey-context";
|
||||
|
||||
interface SurveyLayoutProps {
|
||||
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
||||
const resolvedParams = await params;
|
||||
|
||||
const survey = await getSurvey(resolvedParams.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
|
||||
}
|
||||
|
||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, 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,14 @@ 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([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
getSurveys(projectId),
|
||||
getIntegrations(projectId),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||
@@ -33,9 +36,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 +52,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>
|
||||
|
||||
@@ -19,6 +19,8 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
|
||||
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
.inputSchema(ZValidateGoogleSheetsConnectionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
@@ -29,13 +31,13 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
projectId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
|
||||
const integration = await getIntegrationByType(projectId, "googleSheets");
|
||||
if (!integration) {
|
||||
return { data: false };
|
||||
}
|
||||
|
||||
@@ -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,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
getSurveys(projectId),
|
||||
getIntegrations(projectId),
|
||||
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 +50,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,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getWebhookCountBySource = async (
|
||||
environmentId: string,
|
||||
projectId: string,
|
||||
source?: Webhook["source"]
|
||||
): Promise<number> => {
|
||||
validateInputs([environmentId, ZId], [source, z.string().optional()]);
|
||||
validateInputs([projectId, ZId], [source, z.string().optional()]);
|
||||
|
||||
try {
|
||||
const count = await prisma.webhook.count({
|
||||
where: {
|
||||
environmentId,
|
||||
projectId,
|
||||
source,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,20 @@ 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([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "notion"),
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const [surveys, notionIntegration, locale] = await Promise.all([
|
||||
getSurveys(projectId),
|
||||
getIntegrationByType(projectId, "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 +59,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>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const [
|
||||
integrations,
|
||||
userWebhookCount,
|
||||
@@ -41,12 +43,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
n8nwebhookCount,
|
||||
activePiecesWebhookCount,
|
||||
] = await Promise.all([
|
||||
getIntegrations(params.environmentId),
|
||||
getWebhookCountBySource(params.environmentId, "user"),
|
||||
getWebhookCountBySource(params.environmentId, "zapier"),
|
||||
getWebhookCountBySource(params.environmentId, "make"),
|
||||
getWebhookCountBySource(params.environmentId, "n8n"),
|
||||
getWebhookCountBySource(params.environmentId, "activepieces"),
|
||||
getIntegrations(projectId),
|
||||
getWebhookCountBySource(projectId, "user"),
|
||||
getWebhookCountBySource(projectId, "zapier"),
|
||||
getWebhookCountBySource(projectId, "make"),
|
||||
getWebhookCountBySource(projectId, "n8n"),
|
||||
getWebhookCountBySource(projectId, "activepieces"),
|
||||
]);
|
||||
|
||||
const isIntegrationConnected = (type: TIntegrationType) =>
|
||||
|
||||
@@ -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,16 @@ 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([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
const projectId = environment.projectId;
|
||||
|
||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
||||
getSurveys(projectId),
|
||||
getIntegrationByType(projectId, "slack"),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
@@ -41,7 +42,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>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
CHATWOOT_WEBSITE_TOKEN,
|
||||
IS_CHATWOOT_CONFIGURED,
|
||||
POSTHOG_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
||||
<NoMobileOverlay />
|
||||
{POSTHOG_KEY && user && (
|
||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
)}
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SITE_KEY: "site-key",
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -138,6 +139,7 @@ describe("sendTelemetryEvents", () => {
|
||||
expect(payload.userCount).toBe(5);
|
||||
expect(payload.integrations.notion).toBe(true);
|
||||
expect(payload.sso.github).toBe(true);
|
||||
expect(payload.sso.saml).toBe(true);
|
||||
|
||||
// Check cache update (no TTL parameter)
|
||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||
|
||||
@@ -212,6 +212,7 @@ const sendTelemetry = async (lastSent: number) => {
|
||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
|
||||
};
|
||||
|
||||
// Construct telemetry payload with usage statistics and configuration.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
@@ -152,8 +153,9 @@ export const POST = async (request: Request) => {
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
getIntegrations(projectId),
|
||||
getResponseCountBySurveyId(surveyId),
|
||||
]);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -67,7 +68,8 @@ export const GET = async (req: Request) => {
|
||||
}
|
||||
|
||||
const integrationType = "googleSheets" as const;
|
||||
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const existingIntegration = await getIntegrationByType(projectId, integrationType);
|
||||
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
|
||||
|
||||
const googleSheetIntegration = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
@@ -15,9 +16,11 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
if (userId) {
|
||||
contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
environment: { connect: { id: environmentId } },
|
||||
project: { connect: { id: projectId } },
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -36,7 +37,8 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const integration = (await getIntegrationByType(projectId, "airtable")) as TIntegrationAirtable;
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -88,7 +89,8 @@ export const GET = withV1ApiWrapper({
|
||||
},
|
||||
};
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const existingIntegration = await getIntegrationByType(projectId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -88,7 +89,8 @@ export const GET = withV1ApiWrapper({
|
||||
team: data.team,
|
||||
};
|
||||
|
||||
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const slackIntegration = await getIntegrationByType(projectId, "slack");
|
||||
|
||||
const slackConfiguration: TIntegrationSlackConfig = {
|
||||
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
|
||||
|
||||
@@ -19,6 +19,7 @@ const selectActionClass = {
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
|
||||
@@ -12,6 +13,8 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
await validateWebhookUrl(webhookInput.url);
|
||||
|
||||
const projectId = await getProjectIdFromEnvironmentId(webhookInput.environmentId);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
@@ -23,11 +26,8 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
},
|
||||
},
|
||||
environmentId: webhookInput.environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
324
apps/web/app/api/v3/lib/api-wrapper.test.ts
Normal file
324
apps/web/app/api/v3/lib/api-wrapper.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "./api-wrapper";
|
||||
|
||||
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
mockGetServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: mockGetServerSession,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateRequest: mockAuthenticateRequest,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("withV3ApiWrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("uses session auth first in both mode and injects request id into plain responses", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
|
||||
expect(authentication).toMatchObject({ user: { id: "user_1" } });
|
||||
expect(requestId).toBe("req-1");
|
||||
expect(instance).toBe("/api/v3/surveys");
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
|
||||
headers: { "x-request-id": "req-1" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Request-Id")).toBe("req-1");
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "api:v3" }),
|
||||
"user_1"
|
||||
);
|
||||
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("falls back to api key auth in both mode", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
environmentPermissions: [],
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ authentication }) => {
|
||||
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
headers: { "x-api-key": "fbk_test" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ namespace: "api:v3" }),
|
||||
"key_1"
|
||||
);
|
||||
expect(mockGetServerSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 401 problem response when authentication is required but missing", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid query input", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
query: z.object({
|
||||
limit: z.coerce.number().int().positive(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
|
||||
headers: { "x-request-id": "req-invalid" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
|
||||
expect(body.requestId).toBe("req-invalid");
|
||||
});
|
||||
|
||||
test("parses body, repeated query params, and async route params", async () => {
|
||||
const handler = vi.fn(async ({ parsedInput }) => {
|
||||
expect(parsedInput).toEqual({
|
||||
body: { name: "Survey API" },
|
||||
query: { tag: ["a", "b"] },
|
||||
params: { workspaceId: "ws_123" },
|
||||
});
|
||||
|
||||
return Response.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
"X-Request-Id": "handler-request-id",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
query: z.object({
|
||||
tag: z.array(z.string()),
|
||||
}),
|
||||
params: z.object({
|
||||
workspaceId: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Survey API" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
workspaceId: "ws_123",
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("returns 400 problem response for malformed JSON input", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
body: "{",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body.invalid_params).toEqual([
|
||||
{
|
||||
name: "body",
|
||||
reason: "Malformed JSON input, please check your request body",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns 400 problem response for invalid route params", async () => {
|
||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "none",
|
||||
schemas: {
|
||||
params: z.object({
|
||||
workspaceId: z.string().min(3),
|
||||
}),
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
|
||||
params: Promise.resolve({
|
||||
workspaceId: "x",
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
const body = await response.json();
|
||||
expect(body.invalid_params).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 429 problem response when rate limited", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("Retry-After")).toBe("60");
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("too_many_requests");
|
||||
});
|
||||
|
||||
test("returns 500 problem response when the handler throws unexpectedly", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys", {
|
||||
headers: { "x-request-id": "req-boom" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
expect(body.requestId).toBe("req-boom");
|
||||
});
|
||||
});
|
||||
349
apps/web/app/api/v3/lib/api-wrapper.ts
Normal file
349
apps/web/app/api/v3/lib/api-wrapper.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
problemInternalError,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
|
||||
type TV3Schema = z.ZodTypeAny;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
|
||||
|
||||
export type TV3Schemas = {
|
||||
body?: TV3Schema;
|
||||
query?: TV3Schema;
|
||||
params?: TV3Schema;
|
||||
};
|
||||
|
||||
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
|
||||
? {
|
||||
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
|
||||
}
|
||||
: Record<string, never>;
|
||||
|
||||
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
|
||||
req: NextRequest;
|
||||
props: TProps;
|
||||
authentication: TV3Authentication;
|
||||
parsedInput: TParsedInput;
|
||||
requestId: string;
|
||||
instance: string;
|
||||
};
|
||||
|
||||
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
|
||||
auth?: TV3AuthMode;
|
||||
schemas?: S;
|
||||
rateLimit?: boolean;
|
||||
customRateLimitConfig?: TRateLimitConfig;
|
||||
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
|
||||
};
|
||||
|
||||
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
|
||||
if (authMode === "session") {
|
||||
return "Session required";
|
||||
}
|
||||
|
||||
if (authMode === "apiKey") {
|
||||
return "API key required";
|
||||
}
|
||||
|
||||
return "Not authenticated";
|
||||
}
|
||||
|
||||
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
|
||||
return error.issues.map((issue) => ({
|
||||
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
|
||||
reason: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
|
||||
const query: Record<string, string | string[]> = {};
|
||||
|
||||
for (const key of new Set(searchParams.keys())) {
|
||||
const values = searchParams.getAll(key);
|
||||
query[key] = values.length > 1 ? values : (values[0] ?? "");
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
|
||||
if (!authentication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return authentication.user.id;
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication) {
|
||||
return authentication.apiKeyId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: unknown): value is Promise<T> {
|
||||
return typeof value === "object" && value !== null && "then" in value;
|
||||
}
|
||||
|
||||
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
|
||||
if (!props || typeof props !== "object" || !("params" in props)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params = (props as { params?: unknown }).params;
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
|
||||
return typeof resolvedParams === "object" && resolvedParams !== null
|
||||
? (resolvedParams as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
|
||||
if (authMode === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authMode === "both" && req.headers.has("x-api-key")) {
|
||||
const apiKeyAuth = await authenticateRequest(req);
|
||||
if (apiKeyAuth) {
|
||||
return apiKeyAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMode === "session" || authMode === "both") {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.id) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (authMode === "session") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMode === "apiKey" || authMode === "both") {
|
||||
return await authenticateRequest(req);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
|
||||
req: NextRequest,
|
||||
props: TProps,
|
||||
schemas: S | undefined,
|
||||
requestId: string,
|
||||
instance: string
|
||||
): Promise<
|
||||
| { ok: true; parsedInput: TV3ParsedInput<S> }
|
||||
| {
|
||||
ok: false;
|
||||
response: Response;
|
||||
}
|
||||
> {
|
||||
const parsedInput = {} as TV3ParsedInput<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
let bodyData: unknown;
|
||||
|
||||
try {
|
||||
bodyData = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
instance,
|
||||
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const bodyResult = schemas.body.safeParse(bodyData);
|
||||
if (!bodyResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid request body", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(bodyResult.error, "body"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
|
||||
}
|
||||
|
||||
if (schemas?.query) {
|
||||
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
|
||||
if (!queryResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid query parameters", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(queryResult.error, "query"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
|
||||
}
|
||||
|
||||
if (schemas?.params) {
|
||||
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
|
||||
if (!paramsResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: problemBadRequest(requestId, "Invalid route parameters", {
|
||||
instance,
|
||||
invalid_params: formatZodIssues(paramsResult.error, "params"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
|
||||
}
|
||||
|
||||
return { ok: true, parsedInput };
|
||||
}
|
||||
|
||||
function ensureRequestIdHeader(response: Response, requestId: string): Response {
|
||||
if (response.headers.get("X-Request-Id")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("X-Request-Id", requestId);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticateV3RequestOrRespond(
|
||||
req: NextRequest,
|
||||
authMode: TV3AuthMode,
|
||||
requestId: string,
|
||||
instance: string
|
||||
): Promise<
|
||||
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
|
||||
> {
|
||||
const authentication = await authenticateV3Request(req, authMode);
|
||||
|
||||
if (!authentication && authMode !== "none") {
|
||||
return {
|
||||
authentication: null,
|
||||
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authentication,
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyV3RateLimitOrRespond(params: {
|
||||
authentication: TV3Authentication;
|
||||
enabled: boolean;
|
||||
config: TRateLimitConfig;
|
||||
requestId: string;
|
||||
log: ReturnType<typeof logger.withContext>;
|
||||
}): Promise<Response | null> {
|
||||
const { authentication, enabled, config, requestId, log } = params;
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const identifier = getRateLimitIdentifier(authentication);
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(config, identifier);
|
||||
} catch (error) {
|
||||
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
|
||||
return problemTooManyRequests(
|
||||
requestId,
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
error instanceof TooManyRequestsError ? error.retryAfter : undefined
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
|
||||
params: TWithV3ApiWrapperParams<S, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
|
||||
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
||||
const instance = req.nextUrl.pathname;
|
||||
const log = logger.withContext({
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: instance,
|
||||
});
|
||||
|
||||
try {
|
||||
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
|
||||
if (authResult.response) {
|
||||
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
|
||||
return authResult.response;
|
||||
}
|
||||
|
||||
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
|
||||
if (!parsedInputResult.ok) {
|
||||
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
|
||||
return parsedInputResult.response;
|
||||
}
|
||||
|
||||
const rateLimitResponse = await applyV3RateLimitOrRespond({
|
||||
authentication: authResult.authentication,
|
||||
enabled: rateLimit,
|
||||
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
|
||||
requestId,
|
||||
log,
|
||||
});
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
const response = await handler({
|
||||
req,
|
||||
props,
|
||||
authentication: authResult.authentication,
|
||||
parsedInput: parsedInputResult.parsedInput,
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
|
||||
return ensureRequestIdHeader(response, requestId);
|
||||
} catch (error) {
|
||||
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
};
|
||||
};
|
||||
274
apps/web/app/api/v3/lib/auth.test.ts
Normal file
274
apps/web/app/api/v3/lib/auth.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { getEnvironment } from "@/lib/utils/services";
|
||||
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/services", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestId = "req-123";
|
||||
|
||||
describe("requireSessionWorkspaceAccess", () => {
|
||||
test("returns 401 when authentication is null", async () => {
|
||||
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(401);
|
||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.status).toBe(401);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 401 when authentication is API key (no user)", async () => {
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
|
||||
"proj_abc",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(401);
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_nonexistent",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(403);
|
||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
|
||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when user has no access to workspace", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_abc",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(403);
|
||||
const body = await (result as Response).json();
|
||||
expect(body.requestId).toBe(requestId);
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns workspace context when session is valid and user has access", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const result = await requireSessionWorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_abc",
|
||||
"readWrite",
|
||||
requestId
|
||||
);
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
expect(result).toEqual({
|
||||
environmentId: "env_abc",
|
||||
projectId: "proj_abc",
|
||||
organizationId: "org_1",
|
||||
});
|
||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const keyBase = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_k",
|
||||
organizationAccess: { accessControl: { read: true, write: false } },
|
||||
};
|
||||
|
||||
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
|
||||
return {
|
||||
environmentId,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_k",
|
||||
projectName: "K",
|
||||
permission,
|
||||
};
|
||||
}
|
||||
|
||||
describe("requireV3WorkspaceAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env_k",
|
||||
projectId: "proj_k",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
|
||||
});
|
||||
|
||||
test("401 when authentication is null", async () => {
|
||||
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
|
||||
expect((r as Response).status).toBe(401);
|
||||
});
|
||||
|
||||
test("delegates to session flow when user is present", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
||||
id: "env_s",
|
||||
projectId: "proj_s",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
const r = await requireV3WorkspaceAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"env_s",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
expect(r).toEqual({
|
||||
environmentId: "env_s",
|
||||
projectId: "proj_s",
|
||||
organizationId: "org_s",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns context for API key with read on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_a",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
|
||||
});
|
||||
|
||||
test("returns context for API key with write on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_b",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when API key permission is lower than the required permission", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("403 when API key has no matching environment", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("other_env")],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("403 when API key permission is not list-eligible (runtime value)", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [
|
||||
{
|
||||
...envPerm("ws_c"),
|
||||
permission: "invalid" as unknown as ApiKeyPermission,
|
||||
},
|
||||
],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns context for API key with manage on workspace", async () => {
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
|
||||
expect(r).toEqual({
|
||||
environmentId: "ws_m",
|
||||
projectId: "proj_k",
|
||||
organizationId: "org_k",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||
const auth = {
|
||||
...keyBase,
|
||||
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
|
||||
};
|
||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
|
||||
expect((r as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("401 when auth is neither session nor valid API key payload", async () => {
|
||||
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
|
||||
expect((r as Response).status).toBe(401);
|
||||
});
|
||||
});
|
||||
122
apps/web/app/api/v3/lib/auth.ts
Normal file
122
apps/web/app/api/v3/lib/auth.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* V3 API auth — session (browser) or API key with environment-scoped access.
|
||||
*/
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { problemForbidden, problemUnauthorized } from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
|
||||
const grantedRank = {
|
||||
[ApiKeyPermission.read]: 1,
|
||||
[ApiKeyPermission.write]: 2,
|
||||
[ApiKeyPermission.manage]: 3,
|
||||
}[permission];
|
||||
|
||||
const requiredRank = {
|
||||
read: 1,
|
||||
readWrite: 2,
|
||||
manage: 3,
|
||||
}[minPermission];
|
||||
|
||||
return grantedRank >= requiredRank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
|
||||
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
|
||||
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
|
||||
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
|
||||
*/
|
||||
export async function requireSessionWorkspaceAccess(
|
||||
authentication: TV3Authentication,
|
||||
workspaceId: string,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
// --- Session checks ---
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
if (!("user" in authentication) || !authentication.user?.id) {
|
||||
return problemUnauthorized(requestId, "Session required", instance);
|
||||
}
|
||||
|
||||
const userId = authentication.user.id;
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
|
||||
try {
|
||||
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
|
||||
// Org + project-team access; we use internal IDs from context.
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId: context.organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: context.projectId, minPermission },
|
||||
],
|
||||
});
|
||||
|
||||
return context;
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
|
||||
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, message);
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
|
||||
export async function requireV3WorkspaceAccess(
|
||||
authentication: TV3Authentication,
|
||||
workspaceId: string,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
|
||||
}
|
||||
|
||||
const keyAuth = authentication as TAuthenticationApiKey;
|
||||
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
|
||||
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
|
||||
|
||||
try {
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
const permission = keyAuth.environmentPermissions.find(
|
||||
(environmentPermission) => environmentPermission.environmentId === context.environmentId
|
||||
);
|
||||
|
||||
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
|
||||
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
95
apps/web/app/api/v3/lib/response.test.ts
Normal file
95
apps/web/app/api/v3/lib/response.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
problemNotFound,
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
successListResponse,
|
||||
} from "./response";
|
||||
|
||||
describe("v3 problem responses", () => {
|
||||
test("problemBadRequest includes invalid_params", async () => {
|
||||
const res = problemBadRequest("rid", "bad", {
|
||||
invalid_params: [{ name: "x", reason: "y" }],
|
||||
instance: "/p",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("rid");
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("bad_request");
|
||||
expect(body.requestId).toBe("rid");
|
||||
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
|
||||
expect(body.instance).toBe("/p");
|
||||
});
|
||||
|
||||
test("problemUnauthorized default detail", async () => {
|
||||
const res = problemUnauthorized("r1");
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.detail).toBe("Not authenticated");
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("problemForbidden", async () => {
|
||||
const res = problemForbidden("r2", undefined, "/api/x");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(body.instance).toBe("/api/x");
|
||||
});
|
||||
|
||||
test("problemInternalError", async () => {
|
||||
const res = problemInternalError("r3", "oops", "/i");
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
expect(body.detail).toBe("oops");
|
||||
});
|
||||
|
||||
test("problemNotFound includes details", async () => {
|
||||
const res = problemNotFound("r4", "Survey", "s1", "/s");
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("not_found");
|
||||
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
|
||||
});
|
||||
|
||||
test("problemTooManyRequests with Retry-After", async () => {
|
||||
const res = problemTooManyRequests("r5", "slow down", 60);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("Retry-After")).toBe("60");
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("too_many_requests");
|
||||
});
|
||||
|
||||
test("problemTooManyRequests without Retry-After", async () => {
|
||||
const res = problemTooManyRequests("r6", "nope");
|
||||
expect(res.headers.get("Retry-After")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("successListResponse", () => {
|
||||
test("sets X-Request-Id and default cache", async () => {
|
||||
const res = successListResponse(
|
||||
[{ a: 1 }],
|
||||
{ limit: 10, nextCursor: "cursor-1" },
|
||||
{
|
||||
requestId: "req-x",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-x");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.json()).toEqual({
|
||||
data: [{ a: 1 }],
|
||||
meta: { limit: 10, nextCursor: "cursor-1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("custom Cache-Control", async () => {
|
||||
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
|
||||
});
|
||||
});
|
||||
149
apps/web/app/api/v3/lib/response.ts
Normal file
149
apps/web/app/api/v3/lib/response.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
|
||||
* and list envelope for success responses.
|
||||
*/
|
||||
|
||||
const PROBLEM_JSON = "application/problem+json" as const;
|
||||
const CACHE_NO_STORE = "private, no-store" as const;
|
||||
|
||||
export type InvalidParam = { name: string; reason: string };
|
||||
|
||||
export type ProblemExtension = {
|
||||
code?: string;
|
||||
requestId: string;
|
||||
details?: Record<string, unknown>;
|
||||
invalid_params?: InvalidParam[];
|
||||
};
|
||||
|
||||
export type ProblemBody = {
|
||||
type?: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
instance?: string;
|
||||
} & ProblemExtension;
|
||||
|
||||
function problemResponse(
|
||||
status: number,
|
||||
title: string,
|
||||
detail: string,
|
||||
requestId: string,
|
||||
options?: {
|
||||
type?: string;
|
||||
instance?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
invalid_params?: InvalidParam[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Response {
|
||||
const body: ProblemBody = {
|
||||
title,
|
||||
status,
|
||||
detail,
|
||||
requestId,
|
||||
...(options?.type && { type: options.type }),
|
||||
...(options?.instance && { instance: options.instance }),
|
||||
...(options?.code && { code: options.code }),
|
||||
...(options?.details && { details: options.details }),
|
||||
...(options?.invalid_params && { invalid_params: options.invalid_params }),
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": PROBLEM_JSON,
|
||||
"Cache-Control": CACHE_NO_STORE,
|
||||
"X-Request-Id": requestId,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return Response.json(body, { status, headers });
|
||||
}
|
||||
|
||||
export function problemBadRequest(
|
||||
requestId: string,
|
||||
detail: string,
|
||||
options?: { invalid_params?: InvalidParam[]; instance?: string }
|
||||
): Response {
|
||||
return problemResponse(400, "Bad Request", detail, requestId, {
|
||||
code: "bad_request",
|
||||
instance: options?.instance,
|
||||
invalid_params: options?.invalid_params,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemUnauthorized(
|
||||
requestId: string,
|
||||
detail: string = "Not authenticated",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(401, "Unauthorized", detail, requestId, {
|
||||
code: "not_authenticated",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemForbidden(
|
||||
requestId: string,
|
||||
detail: string = "You are not authorized to access this resource",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(403, "Forbidden", detail, requestId, {
|
||||
code: "forbidden",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
|
||||
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
|
||||
* Prefer problemForbidden with a generic message for those cases.
|
||||
*/
|
||||
export function problemNotFound(
|
||||
requestId: string,
|
||||
resourceType: string,
|
||||
resourceId: string | null,
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
|
||||
code: "not_found",
|
||||
details: { resource_type: resourceType, resource_id: resourceId },
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemInternalError(
|
||||
requestId: string,
|
||||
detail: string = "An unexpected error occurred.",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(500, "Internal Server Error", detail, requestId, {
|
||||
code: "internal_server_error",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
|
||||
const headers: Record<string, string> = {};
|
||||
if (retryAfter !== undefined) {
|
||||
headers["Retry-After"] = String(retryAfter);
|
||||
}
|
||||
return problemResponse(429, "Too Many Requests", detail, requestId, {
|
||||
code: "too_many_requests",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function successListResponse<T, TMeta extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
meta: TMeta,
|
||||
options?: { requestId?: string; cache?: string }
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
};
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
return Response.json({ data, meta }, { status: 200, headers });
|
||||
}
|
||||
4
apps/web/app/api/v3/lib/types.ts
Normal file
4
apps/web/app/api/v3/lib/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TV3Authentication = TAuthenticationApiKey | Session | null;
|
||||
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
122
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.test.ts
Normal file
122
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.test.ts
Normal file
@@ -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.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
159
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
Normal file
159
apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
|
||||
* Keeps HTTP parsing separate from the route handler and shared survey list service.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
type TSurveyFilterCriteria,
|
||||
ZSurveyFilters,
|
||||
ZSurveyStatus,
|
||||
ZSurveyType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
type TSurveyListPageCursor,
|
||||
type TSurveyListSort,
|
||||
decodeSurveyListPageCursor,
|
||||
normalizeSurveyListSort,
|
||||
} from "@/modules/survey/list/lib/survey-page";
|
||||
|
||||
const V3_SURVEYS_DEFAULT_LIMIT = 20;
|
||||
const V3_SURVEYS_MAX_LIMIT = 100;
|
||||
|
||||
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
|
||||
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
|
||||
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
|
||||
|
||||
const SUPPORTED_QUERY_PARAMS = [
|
||||
"workspaceId",
|
||||
"limit",
|
||||
"cursor",
|
||||
FILTER_NAME_CONTAINS_QUERY_PARAM,
|
||||
FILTER_STATUS_IN_QUERY_PARAM,
|
||||
FILTER_TYPE_IN_QUERY_PARAM,
|
||||
"sortBy",
|
||||
] as const;
|
||||
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
|
||||
|
||||
type InvalidParam = { name: string; reason: string };
|
||||
|
||||
/** Collect repeated query keys and comma-separated values for operator-style filters. */
|
||||
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
|
||||
const acc: string[] = [];
|
||||
for (const raw of searchParams.getAll(key)) {
|
||||
for (const part of raw.split(",")) {
|
||||
const t = part.trim();
|
||||
if (t) acc.push(t);
|
||||
}
|
||||
}
|
||||
return [...new Set(acc)];
|
||||
}
|
||||
|
||||
const ZV3SurveysListQuery = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
|
||||
cursor: z.string().min(1).optional(),
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
|
||||
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
|
||||
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
|
||||
sortBy: ZSurveyFilters.shape.sortBy.optional(),
|
||||
});
|
||||
|
||||
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
|
||||
|
||||
export type TV3SurveysListQueryParseResult =
|
||||
| {
|
||||
ok: true;
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria: TSurveyFilterCriteria | undefined;
|
||||
}
|
||||
| { ok: false; invalid_params: InvalidParam[] };
|
||||
|
||||
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
|
||||
const unsupportedParams = [
|
||||
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
|
||||
];
|
||||
|
||||
return unsupportedParams.map((name) => ({
|
||||
name,
|
||||
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
|
||||
const f: TSurveyFilterCriteria = {};
|
||||
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
|
||||
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
|
||||
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
|
||||
return Object.keys(f).length > 0 ? f : undefined;
|
||||
}
|
||||
|
||||
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
|
||||
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
|
||||
if (unsupportedQueryParams.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: unsupportedQueryParams,
|
||||
};
|
||||
}
|
||||
|
||||
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
|
||||
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
|
||||
|
||||
const raw = {
|
||||
workspaceId: searchParams.get("workspaceId"),
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
cursor: searchParams.get("cursor")?.trim() || undefined,
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
|
||||
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
|
||||
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
|
||||
sortBy: searchParams.get("sortBy")?.trim() || undefined,
|
||||
};
|
||||
|
||||
const result = ZV3SurveysListQuery.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: result.error.issues.map((issue) => ({
|
||||
name: issue.path.join(".") || "query",
|
||||
reason: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const q = result.data;
|
||||
const sortBy = normalizeSurveyListSort(q.sortBy);
|
||||
let cursor: TSurveyListPageCursor | null = null;
|
||||
|
||||
if (q.cursor) {
|
||||
try {
|
||||
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
invalid_params: [
|
||||
{
|
||||
name: "cursor",
|
||||
reason: error instanceof Error ? error.message : "The cursor is invalid.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
workspaceId: q.workspaceId,
|
||||
limit: q.limit,
|
||||
cursor,
|
||||
sortBy,
|
||||
filterCriteria: buildFilterCriteria(q),
|
||||
};
|
||||
}
|
||||
357
apps/web/app/api/v3/surveys/route.test.ts
Normal file
357
apps/web/app/api/v3/surveys/route.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
|
||||
return { ...actual, authenticateRequest: mockAuthenticateRequest };
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
|
||||
return {
|
||||
...actual,
|
||||
getSurveyListPage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
|
||||
return {
|
||||
...actual,
|
||||
getSurveyCount: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
|
||||
|
||||
const validWorkspaceId = "clxx1234567890123456789012";
|
||||
const resolvedEnvironmentId = "clzz9876543210987654321098";
|
||||
|
||||
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (requestId) headers["x-request-id"] = requestId;
|
||||
return new NextRequest(url, { headers });
|
||||
}
|
||||
|
||||
const apiKeyAuth = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: {
|
||||
accessControl: { read: true, write: false },
|
||||
},
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: validWorkspaceId,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_1",
|
||||
projectName: "P",
|
||||
permission: ApiKeyPermission.read,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("GET /api/v3/surveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "User", email: "u@example.com" },
|
||||
expires: "2026-01-01",
|
||||
} as any);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
|
||||
if (auth && "apiKeyId" in auth) {
|
||||
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
|
||||
if (!p) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
);
|
||||
}
|
||||
return {
|
||||
environmentId: workspaceId,
|
||||
projectId: p.projectId,
|
||||
organizationId: auth.organizationId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
environmentId: resolvedEnvironmentId,
|
||||
projectId: "proj_1",
|
||||
organizationId: "org_1",
|
||||
};
|
||||
});
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when no session and no API key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 200 with session and valid workspaceId", async () => {
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-456");
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
validWorkspaceId,
|
||||
"read",
|
||||
"req-456",
|
||||
"/api/v3/surveys"
|
||||
);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
|
||||
});
|
||||
|
||||
test("returns 200 with x-api-key when workspace is on the key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
|
||||
"x-api-key": "fbk_test",
|
||||
});
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: "key_1" }),
|
||||
validWorkspaceId,
|
||||
"read",
|
||||
"req-k",
|
||||
"/api/v3/surveys"
|
||||
);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
|
||||
});
|
||||
|
||||
test("returns 403 when API key does not include workspace", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
...apiKeyAuth,
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "claa1111111111111111111111",
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "proj_x",
|
||||
projectName: "X",
|
||||
permission: ApiKeyPermission.read,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
|
||||
"x-api-key": "fbk_test",
|
||||
});
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns 400 when the createdBy filter is used", async () => {
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is missing", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/surveys");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is not cuid2", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 400 when limit exceeds max", async () => {
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("reflects limit, nextCursor, and totalCount in meta", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [],
|
||||
nextCursor: "cursor-123",
|
||||
});
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(42);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 10,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria: undefined,
|
||||
});
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
|
||||
});
|
||||
|
||||
test("passes filter query to getSurveyListPage", async () => {
|
||||
const filterCriteria = { status: ["inProgress"] };
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
|
||||
limit: 20,
|
||||
cursor: null,
|
||||
sortBy: "updatedAt",
|
||||
filterCriteria,
|
||||
});
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
|
||||
});
|
||||
|
||||
test("returns 400 when filterCriteria is used", async () => {
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when auth returns 403", async () => {
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req-789",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
)
|
||||
);
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [
|
||||
{
|
||||
id: "s1",
|
||||
name: "Survey 1",
|
||||
environmentId: "env_1",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
responseCount: 0,
|
||||
creator: { name: "Test" },
|
||||
singleUse: null,
|
||||
} as any,
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
const body = await res.json();
|
||||
expect(body.data[0]).not.toHaveProperty("blocks");
|
||||
expect(body.data[0]).not.toHaveProperty("singleUse");
|
||||
expect(body.data[0]).not.toHaveProperty("_count");
|
||||
expect(body.data[0]).not.toHaveProperty("environmentId");
|
||||
expect(body.data[0].id).toBe("s1");
|
||||
expect(body.data[0].workspaceId).toBe("env_1");
|
||||
});
|
||||
|
||||
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
});
|
||||
|
||||
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
|
||||
test("returns 500 on unexpected error from getSurveyListPage", async () => {
|
||||
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
|
||||
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
});
|
||||
81
apps/web/app/api/v3/surveys/route.ts
Normal file
81
apps/web/app/api/v3/surveys/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* GET /api/v3/surveys — list surveys for a workspace.
|
||||
* Session cookie or x-api-key; scope by workspaceId only.
|
||||
*/
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
successListResponse,
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
|
||||
import { serializeV3SurveyListItem } from "./serializers";
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async ({ req, authentication, requestId, instance }) => {
|
||||
const log = logger.withContext({ requestId });
|
||||
|
||||
try {
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const parsed = parseV3SurveysListQuery(searchParams);
|
||||
if (!parsed.ok) {
|
||||
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
|
||||
return problemBadRequest(requestId, "Invalid query parameters", {
|
||||
invalid_params: parsed.invalid_params,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
parsed.workspaceId,
|
||||
"read",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const { projectId } = authResult;
|
||||
|
||||
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
|
||||
getSurveyListPage(projectId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
}),
|
||||
getSurveyCount(projectId, 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
18
apps/web/app/api/v3/surveys/serializers.ts
Normal file
18
apps/web/app/api/v3/surveys/serializers.ts
Normal file
@@ -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;
|
||||
};
|
||||
|
||||
18
apps/web/app/chatwoot/actions.ts
Normal file
18
apps/web/app/chatwoot/actions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
|
||||
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
|
||||
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
const organizations = await getOrganizationsByUserId(ctx.user.id);
|
||||
return organizations.some((organization) => {
|
||||
const stripe = organization.billing.stripe;
|
||||
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
|
||||
const isActiveSubscription =
|
||||
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
|
||||
return isPaidPlan && isActiveSubscription;
|
||||
});
|
||||
});
|
||||
21
apps/web/app/components/NoScriptWarning.tsx
Normal file
21
apps/web/app/components/NoScriptWarning.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TUserLocale } from "@formbricks/types/user";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
|
||||
interface NoScriptWarningProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
|
||||
const t = await getTranslate(locale);
|
||||
|
||||
return (
|
||||
<noscript>
|
||||
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
|
||||
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
@@ -26,6 +27,7 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
<NoScriptWarning locale={locale} />
|
||||
<SentryProvider
|
||||
sentryDsn={SENTRY_DSN}
|
||||
sentryRelease={SENTRY_RELEASE}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -4823,6 +4823,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
name: t("templates.preview_survey_name"),
|
||||
type: "link" as const,
|
||||
environmentId: "cltwumfcz0009echxg02fh7oa",
|
||||
projectId: null,
|
||||
createdBy: "cltwumfbz0000echxysz6ptvq",
|
||||
status: "inProgress" as const,
|
||||
welcomeCard: {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -140,6 +140,7 @@ checksums:
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
common/connected: aa0ceca574641de34c74b9e590664230
|
||||
common/contact: 9afa39bc47019ee6dec6c74b6273967c
|
||||
common/contacts: d5b6c3f890b3904eaf5754081945c03d
|
||||
common/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
|
||||
@@ -147,6 +148,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
|
||||
@@ -186,12 +188,12 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 228a8668be1812e031f438d166861729
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
@@ -228,11 +230,13 @@ 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
|
||||
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
|
||||
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
|
||||
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
|
||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
@@ -253,7 +257,9 @@ checksums:
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -267,6 +273,7 @@ checksums:
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
|
||||
@@ -292,10 +299,9 @@ checksums:
|
||||
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
@@ -312,6 +318,7 @@ checksums:
|
||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
@@ -353,6 +360,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
|
||||
@@ -372,7 +380,7 @@ checksums:
|
||||
common/something_went_wrong: a3cd2f01c073f1f5ff436d4b132d39cf
|
||||
common/something_went_wrong_please_try_again: c62a7718d9a1e9c4ffb707807550f836
|
||||
common/sort_by: 8adf3dbc5668379558957662f0c43563
|
||||
common/upgrade_plan: 4fab76a3fc5d5c94e3248cd279cfdd14
|
||||
common/start_free_trial: e346e4ed7d138dcc873db187922369da
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
@@ -385,7 +393,6 @@ checksums:
|
||||
common/survey_id: 08303e98b3d4134947256e494b0c829e
|
||||
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
|
||||
common/survey_live: d1f370505c67509e7b2759952daba20d
|
||||
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
|
||||
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
|
||||
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
|
||||
common/surveys: 33f68ad4111b32a6361beb9d5c184533
|
||||
@@ -400,7 +407,6 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -417,13 +423,13 @@ checksums:
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
common/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
common/upgrade_plan: 81c9e7a593c0e9290f7078ecdc1c6693
|
||||
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
|
||||
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
|
||||
common/url: ca97457614226960d41dd18c3c29c86b
|
||||
common/user: 61073457a5c3901084b557d065f876be
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
@@ -439,14 +445,13 @@ checksums:
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
|
||||
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
|
||||
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
@@ -622,7 +627,6 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
@@ -802,9 +806,16 @@ checksums:
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
|
||||
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
|
||||
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
|
||||
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
|
||||
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
|
||||
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
|
||||
@@ -917,30 +928,57 @@ checksums:
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
environments/settings/billing/add_payment_method: 38ad2a7f6bc599bf596eab394b379c02
|
||||
environments/settings/billing/cancelling: 6e46e789720395bfa1e3a4b3b1519634
|
||||
environments/settings/billing/add_payment_method_to_upgrade_tooltip: 977005ad38bfe0800a78c21edcd16e4d
|
||||
environments/settings/billing/billing_interval_toggle: 62c76eb73507108fc6aefdf1ab86cc38
|
||||
environments/settings/billing/current_plan_badge: 27f172f76ac28e72cb062f80002b0ad5
|
||||
environments/settings/billing/current_plan_cta: 53ac259fd40a361274861ee7c7498424
|
||||
environments/settings/billing/custom_plan_description: 53faa38123cc74e5adc7e59630641d66
|
||||
environments/settings/billing/custom_plan_title: f3b71be0d1cd4f81a177ada040119f30
|
||||
environments/settings/billing/failed_to_start_trial: 43e28223f51af382042b3a753d9e4380
|
||||
environments/settings/billing/manage_subscription: b83a75127b8eabc21dfa1e0f7104db56
|
||||
environments/settings/billing/keep_current_plan: 57ac15ffa2c29ac364dd405669eeb7f6
|
||||
environments/settings/billing/manage_billing_details: 40448f0b5ed4b3bb1d864ba6e1bb6a3b
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
|
||||
environments/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
|
||||
environments/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
|
||||
environments/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
|
||||
environments/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_change_applied: d1e04599487247dd0e21a7d99785dc7a
|
||||
environments/settings/billing/plan_change_scheduled: 16455d4aa9a152b156ee434d8c7e34d4
|
||||
environments/settings/billing/plan_custom: b7b89901f46267f532600a23cfc54ae2
|
||||
environments/settings/billing/plan_feature_everything_in_hobby: 5417a498136fa29988c8215291e3fd8b
|
||||
environments/settings/billing/plan_feature_everything_in_pro: 3f5129ff1f01eed4f051a8790ed62997
|
||||
environments/settings/billing/plan_hobby: 3e96a8e688032f9bd21b436bc70c19d5
|
||||
environments/settings/billing/plan_hobby_description: 1fa1cf69b42ec82727aebc5ef1ec24a2
|
||||
environments/settings/billing/plan_hobby_feature_responses: d1e6c1d83f5e57cbae2a09e6a818a25d
|
||||
environments/settings/billing/plan_hobby_feature_workspaces: 02a34669419ed7f30f728980f54d42ef
|
||||
environments/settings/billing/plan_pro: 682b3c9feab30112b4454cb5bb7974b1
|
||||
environments/settings/billing/plan_pro_description: 748c848ea0d8cf81a66704762edcd6f4
|
||||
environments/settings/billing/plan_pro_feature_responses: e16ffe385051a16dba76538c13d97a5f
|
||||
environments/settings/billing/plan_pro_feature_workspaces: 819874022b491209ca7f0f1ab1e3daea
|
||||
environments/settings/billing/plan_scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
environments/settings/billing/plan_scale_description: ef5c66e0b52686f56319e31388bd8409
|
||||
environments/settings/billing/plan_scale_feature_responses: 0b74bf8d089c738ebb7f0867bdd7d7f1
|
||||
environments/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef
|
||||
environments/settings/billing/plan_selection_description: 8367b137b31234cafe0e297a35b0b599
|
||||
environments/settings/billing/plan_selection_title: 8b814effdaee1787281b740f67482d7d
|
||||
environments/settings/billing/plan_unknown: 5cd12b882fe90320f93130c1b50e2e32
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/retry_setup: bef560e42fa8798271fea150476791e0
|
||||
environments/settings/billing/scale_banner_description: 79a9734c77ab0336d5d2fadb5f2151be
|
||||
environments/settings/billing/scale_banner_title: a2a78f57ebcbf444ad881ece234b8f45
|
||||
environments/settings/billing/scale_feature_api: 67231215e5452944b86edc2bc47d2a16
|
||||
environments/settings/billing/scale_feature_quota: 31fb6b5e846dd44de140a69fd3e4c067
|
||||
environments/settings/billing/scale_feature_spam: 8a8229b6ac3f3e0427fd347cb667ce11
|
||||
environments/settings/billing/scale_feature_teams: f6e8428f6cdb227176a5fa8c5c95c976
|
||||
environments/settings/billing/select_plan_header_subtitle: 8de6b4e3ce5726829829bd46582f343a
|
||||
environments/settings/billing/select_plan_header_title: d851e9fa093ddb248924cf99e1d79b4e
|
||||
environments/settings/billing/select_plan_header_title: b15a9d86b819a7fae8e956a50572184c
|
||||
environments/settings/billing/status_trialing: 4fd32760caf3bd7169935b0a6d2b5b67
|
||||
environments/settings/billing/stay_on_hobby_plan: 966ab0c752a79f00ef10d6a5ed1d8cad
|
||||
environments/settings/billing/stripe_setup_incomplete: fa6d6e295dd14b73c17ac8678205109b
|
||||
environments/settings/billing/stripe_setup_incomplete_description: 9f28a542729cc719bca2ca08e7406284
|
||||
environments/settings/billing/subscription: ba9f3675e18987d067d48533c8897343
|
||||
environments/settings/billing/subscription_description: b03618508e576666198d4adf3c2cb9a9
|
||||
environments/settings/billing/switch_at_period_end: 9c91b2287886e077a0571efab8908623
|
||||
environments/settings/billing/switch_plan_now: dad56622a1916fe5d1a2bda5b0393194
|
||||
environments/settings/billing/this_includes: 127e0fe104f47886b54106a057a6b26f
|
||||
environments/settings/billing/trial_alert_description: aba3076cc6814cc6128d425d3d1957e8
|
||||
environments/settings/billing/trial_already_used: 5433347ff7647fe0aba0fe91a44560ba
|
||||
environments/settings/billing/trial_feature_api_access: 8c6d03728c3d9470616eb5cee5f9f65d
|
||||
@@ -958,8 +996,11 @@ checksums:
|
||||
environments/settings/billing/unlimited_responses: 25bd1cd99bc08c66b8d7d3380b2812e1
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
|
||||
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
|
||||
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
|
||||
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
|
||||
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
|
||||
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
|
||||
environments/settings/domain/customize_favicon_description: d3ac29934a66fd56294c0d8069fbc11e
|
||||
environments/settings/domain/customize_favicon_with_higher_plan: 43a6b834a8fd013c52923863d62248f3
|
||||
@@ -981,6 +1022,25 @@ checksums:
|
||||
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
|
||||
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
|
||||
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
|
||||
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
|
||||
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
|
||||
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
|
||||
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
|
||||
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
|
||||
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
|
||||
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
|
||||
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
|
||||
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
|
||||
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
|
||||
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
|
||||
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
|
||||
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
|
||||
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
|
||||
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
|
||||
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
|
||||
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
|
||||
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
|
||||
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
|
||||
@@ -1290,7 +1350,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
|
||||
@@ -1328,6 +1387,7 @@ checksums:
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
|
||||
@@ -1551,6 +1611,8 @@ checksums:
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
|
||||
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
@@ -1579,6 +1641,7 @@ 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
|
||||
@@ -1594,10 +1657,12 @@ checksums:
|
||||
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
|
||||
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
|
||||
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
|
||||
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
|
||||
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
@@ -2866,7 +2931,7 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
@@ -3119,7 +3184,7 @@ checksums:
|
||||
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
||||
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
|
||||
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
|
||||
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
|
||||
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
|
||||
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClassByProjectIdAndName,
|
||||
getActionClasses,
|
||||
} from "./service";
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("ActionClass Service", () => {
|
||||
const result = await getActionClasses("env1");
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: "env1" },
|
||||
where: { projectId: "env1" },
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
@@ -63,7 +63,7 @@ describe("ActionClass Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActionClassByEnvironmentIdAndName", () => {
|
||||
describe("getActionClassByProjectIdAndName", () => {
|
||||
test("should return action class when found", async () => {
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "id2",
|
||||
@@ -83,10 +83,10 @@ describe("ActionClass Service", () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
|
||||
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
const result = await getActionClassByProjectIdAndName("env2", "Action 2");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
|
||||
where: { name: "Action 2", environmentId: "env2" },
|
||||
where: { name: "Action 2", projectId: "env2" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
@@ -94,14 +94,14 @@ describe("ActionClass Service", () => {
|
||||
test("should return null when not found", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
const result = await getActionClassByProjectIdAndName("env2", "Action 2");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
|
||||
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
|
||||
await expect(getActionClassByProjectIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const selectActionClass = {
|
||||
@@ -21,16 +22,17 @@ const selectActionClass = {
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TActionClass[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
async (projectId: string, page?: number): Promise<TActionClass[]> => {
|
||||
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
projectId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
@@ -40,21 +42,21 @@ export const getActionClasses = reactCache(
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
|
||||
throw new DatabaseError(`Database error when fetching actions for project ${projectId}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// This function is used to get an action by its name and environmentId(it can return private actions as well)
|
||||
export const getActionClassByEnvironmentIdAndName = reactCache(
|
||||
async (environmentId: string, name: string): Promise<TActionClass | null> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
// This function is used to get an action by its name and projectId(it can return private actions as well)
|
||||
export const getActionClassByProjectIdAndName = reactCache(
|
||||
async (projectId: string, name: string): Promise<TActionClass | null> => {
|
||||
validateInputs([projectId, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
const actionClass = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
@@ -113,10 +115,13 @@ export const createActionClass = async (
|
||||
const { environmentId: _, ...actionClassInput } = actionClass;
|
||||
|
||||
try {
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
const actionClassPrisma = await prisma.actionClass.create({
|
||||
data: {
|
||||
...actionClassInput,
|
||||
environment: { connect: { id: environmentId } },
|
||||
environmentId,
|
||||
projectId,
|
||||
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
|
||||
noCodeConfig:
|
||||
actionClassInput.type === "noCode"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
import { delay } from "../utils/promises";
|
||||
import { truncateText } from "../utils/strings";
|
||||
|
||||
@@ -78,10 +79,8 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
|
||||
|
||||
export const getAirtableToken = async (environmentId: string) => {
|
||||
try {
|
||||
const airtableIntegration = (await getIntegrationByType(
|
||||
environmentId,
|
||||
"airtable"
|
||||
)) as TIntegrationAirtable;
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const airtableIntegration = (await getIntegrationByType(projectId, "airtable")) as TIntegrationAirtable;
|
||||
|
||||
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
||||
airtableIntegration?.config.key
|
||||
|
||||
@@ -40,6 +40,8 @@ export const GITHUB_ID = env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = env.GITHUB_SECRET;
|
||||
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
|
||||
export const HUB_API_URL = env.HUB_API_URL;
|
||||
export const HUB_API_KEY = env.HUB_API_KEY;
|
||||
|
||||
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
|
||||
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;
|
||||
|
||||
@@ -33,6 +33,8 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
HTTP_PROXY: z.url().optional(),
|
||||
HTTPS_PROXY: z.url().optional(),
|
||||
HUB_API_URL: z.url(),
|
||||
HUB_API_KEY: z.string().optional(),
|
||||
IMPRINT_URL: z
|
||||
.url()
|
||||
.optional()
|
||||
@@ -85,7 +87,6 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||
STRIPE_PRICING_TABLE_ID: z.string().optional(),
|
||||
PUBLIC_URL: z
|
||||
.url()
|
||||
.refine(
|
||||
@@ -160,6 +161,8 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
HUB_API_URL: process.env.HUB_API_URL,
|
||||
HUB_API_KEY: process.env.HUB_API_KEY,
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
|
||||
INVITE_DISABLED: process.env.INVITE_DISABLED,
|
||||
@@ -203,7 +206,6 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
|
||||
STRIPE_PRICING_TABLE_ID: process.env.STRIPE_PRICING_TABLE_ID,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const transformIntegration = (integration: TIntegration): TIntegration => {
|
||||
@@ -28,6 +29,8 @@ export const createOrUpdateIntegration = async (
|
||||
): Promise<TIntegration> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
try {
|
||||
const integration = await prisma.integration.upsert({
|
||||
where: {
|
||||
@@ -38,11 +41,13 @@ export const createOrUpdateIntegration = async (
|
||||
},
|
||||
update: {
|
||||
...integrationData,
|
||||
environment: { connect: { id: environmentId } },
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
create: {
|
||||
...integrationData,
|
||||
environment: { connect: { id: environmentId } },
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
return integration;
|
||||
@@ -56,13 +61,13 @@ export const createOrUpdateIntegration = async (
|
||||
};
|
||||
|
||||
export const getIntegrations = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TIntegration[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
async (projectId: string, page?: number): Promise<TIntegration[]> => {
|
||||
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const integrations = await prisma.integration.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
@@ -94,16 +99,14 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
|
||||
});
|
||||
|
||||
export const getIntegrationByType = reactCache(
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
async (projectId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
validateInputs([projectId, ZId], [type, ZIntegrationType]);
|
||||
|
||||
try {
|
||||
const integration = await prisma.integration.findUnique({
|
||||
const integration = await prisma.integration.findFirst({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId,
|
||||
type,
|
||||
},
|
||||
projectId,
|
||||
type,
|
||||
},
|
||||
});
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "../../organization/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "../service";
|
||||
|
||||
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipRole(userId, organization.id);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIntegrationByType } from "../integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
|
||||
const fetchPages = async (config: TIntegrationNotionConfig) => {
|
||||
try {
|
||||
@@ -29,7 +30,8 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
|
||||
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
||||
let results: TIntegrationNotionDatabase[] = [];
|
||||
try {
|
||||
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const notionIntegration = (await getIntegrationByType(projectId, "notion")) as TIntegrationNotion;
|
||||
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
||||
results = await fetchPages(notionIntegration.config);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -378,7 +379,7 @@ export const getResponseDownloadFile = async (
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
|
||||
if (!organizationId) {
|
||||
throw new Error("Organization ID not found");
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
import { truncateText } from "../utils/strings";
|
||||
|
||||
export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIntegrationItem[]> => {
|
||||
@@ -58,7 +59,8 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
|
||||
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
|
||||
let channels: TIntegrationItem[] = [];
|
||||
try {
|
||||
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
const slackIntegration = (await getIntegrationByType(projectId, "slack")) as TIntegrationSlack;
|
||||
if (slackIntegration && slackIntegration.config?.key) {
|
||||
channels = await fetchChannels(slackIntegration);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const selectContact = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -41,6 +42,7 @@ const commonMockProperties = {
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
environmentId: mockId,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
type SurveyMock = Prisma.SurveyGetPayload<{
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProjectIdFromEnvironmentId } from "../utils/helper";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import {
|
||||
checkForInvalidImagesInQuestions,
|
||||
@@ -30,6 +31,7 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
createdBy: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
@@ -84,6 +86,7 @@ export const selectSurvey = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
@@ -243,13 +246,13 @@ export const getSurveysByActionClassId = reactCache(
|
||||
);
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentId: string, limit?: number, offset?: number): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
async (projectId: string, limit?: number, offset?: number): Promise<TSurvey[]> => {
|
||||
validateInputs([projectId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
@@ -270,12 +273,12 @@ export const getSurveys = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
export const getSurveyCount = reactCache(async (projectId: string): Promise<number> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,6 +474,11 @@ export const updateSurveyInternal = async (
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: currentSurvey.projectId!,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -624,7 +632,10 @@ export const createSurvey = async (
|
||||
};
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(parsedEnvironmentId);
|
||||
const [organization, projectId] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(parsedEnvironmentId),
|
||||
getProjectIdFromEnvironmentId(parsedEnvironmentId),
|
||||
]);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
@@ -659,6 +670,11 @@ export const createSurvey = async (
|
||||
id: parsedEnvironmentId,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: selectSurvey,
|
||||
});
|
||||
@@ -670,11 +686,8 @@ export const createSurvey = async (
|
||||
title: survey.id,
|
||||
filters: [],
|
||||
isPrivate: true,
|
||||
environment: {
|
||||
connect: {
|
||||
id: parsedEnvironmentId,
|
||||
},
|
||||
},
|
||||
environmentId: parsedEnvironmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
|
||||
import { createTag, getTag, getTagsByProjectId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -21,8 +21,8 @@ describe("Tag Service", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getTagsByEnvironmentId", () => {
|
||||
test("should return tags for a given environment ID", async () => {
|
||||
describe("getTagsByProjectId", () => {
|
||||
test("should return tags for a given project ID", async () => {
|
||||
const mockTags: TTag[] = [
|
||||
{
|
||||
id: "tag1",
|
||||
@@ -35,11 +35,11 @@ describe("Tag Service", () => {
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1");
|
||||
const result = await getTagsByProjectId("env1");
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
projectId: "env1",
|
||||
},
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
@@ -59,11 +59,11 @@ describe("Tag Service", () => {
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1", 1);
|
||||
const result = await getTagsByProjectId("env1", 1);
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
projectId: "env1",
|
||||
},
|
||||
take: 30,
|
||||
skip: 0,
|
||||
|
||||
@@ -6,29 +6,28 @@ import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const getTagsByEnvironmentId = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TTag[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
export const getTagsByProjectId = reactCache(async (projectId: string, page?: number): Promise<TTag[]> => {
|
||||
validateInputs([projectId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
try {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return tags;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return tags;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
@@ -52,11 +51,14 @@ export const createTag = async (
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
try {
|
||||
const tag = await prisma.tag.create({
|
||||
data: {
|
||||
name,
|
||||
environmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,120 +1,33 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { type Locale, formatDistance } from "date-fns";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatDateForDisplay } from "./utils/datetime";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
if (dateString === null) return null;
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
const DEFAULT_LOCALE: TUserLocale = "en-US";
|
||||
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
|
||||
"de-DE": de,
|
||||
"en-US": enUS,
|
||||
"es-ES": es,
|
||||
"fr-FR": fr,
|
||||
"hu-HU": hu,
|
||||
"ja-JP": ja,
|
||||
"nl-NL": nl,
|
||||
"pt-BR": ptBR,
|
||||
"pt-PT": pt,
|
||||
"ro-RO": ro,
|
||||
"ru-RU": ru,
|
||||
"sv-SE": sv,
|
||||
"zh-Hans-CN": zhCN,
|
||||
"zh-Hant-TW": zhTW,
|
||||
};
|
||||
|
||||
export const convertDateTimeString = (dateString: string) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale);
|
||||
|
||||
export const convertDateTimeStringShort = (dateString: string) => {
|
||||
if (!dateString) {
|
||||
return dateString;
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */
|
||||
const getLocaleForTimeSince = (locale: string): Locale =>
|
||||
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS;
|
||||
|
||||
export const convertTimeString = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return intlFormat(
|
||||
date,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
},
|
||||
{
|
||||
locale: "en",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
switch (locale) {
|
||||
case "de-DE":
|
||||
return de;
|
||||
case "en-US":
|
||||
return enUS;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "hu-HU":
|
||||
return hu;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeSince = (dateString: string, locale: TUserLocale) => {
|
||||
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => {
|
||||
const date = new Date(dateString);
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
@@ -122,27 +35,21 @@ export const timeSince = (dateString: string, locale: TUserLocale) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const timeSinceDate = (date: Date) => {
|
||||
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
|
||||
return formatDistance(date, new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getLocaleForTimeSince(locale),
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date) => {
|
||||
return intlFormat(date, {
|
||||
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => {
|
||||
return formatDateForDisplay(date, locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export const getTodaysDateFormatted = (seperator: string) => {
|
||||
const date = new Date();
|
||||
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
|
||||
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export const getTodaysDateTimeFormatted = (seperator: string) => {
|
||||
const date = new Date();
|
||||
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
|
||||
|
||||
67
apps/web/lib/utils/date-display.test.ts
Normal file
67
apps/web/lib/utils/date-display.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
|
||||
|
||||
describe("date display utils", () => {
|
||||
test("parses ISO stored dates", () => {
|
||||
const parsedDate = parseStoredDateValue("2025-05-06");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("parses legacy stored dates using the element format", () => {
|
||||
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("parses day-first stored dates when no format is provided", () => {
|
||||
const parsedDate = parseStoredDateValue("06-05-2025");
|
||||
|
||||
expect(parsedDate).not.toBeNull();
|
||||
expect(parsedDate?.getFullYear()).toBe(2025);
|
||||
expect(parsedDate?.getMonth()).toBe(4);
|
||||
expect(parsedDate?.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
test("formats stored dates using the selected locale", () => {
|
||||
const date = new Date(2025, 4, 6);
|
||||
|
||||
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
|
||||
new Intl.DateTimeFormat("de-DE", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date)
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for invalid stored dates", () => {
|
||||
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
|
||||
});
|
||||
|
||||
test("builds a date format map for survey date elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
id: "dateQuestion",
|
||||
type: "date",
|
||||
format: "d-M-y",
|
||||
},
|
||||
{
|
||||
id: "textQuestion",
|
||||
type: "openText",
|
||||
},
|
||||
] as TSurveyElement[];
|
||||
|
||||
expect(getSurveyDateFormatMap(elements)).toEqual({
|
||||
dateQuestion: "d-M-y",
|
||||
});
|
||||
});
|
||||
});
|
||||
85
apps/web/lib/utils/date-display.ts
Normal file
85
apps/web/lib/utils/date-display.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { formatDateWithOrdinal } from "./datetime";
|
||||
|
||||
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
|
||||
|
||||
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
|
||||
|
||||
const buildDate = (year: number, month: number, day: number): Date | null => {
|
||||
if ([year, month, day].some((value) => Number.isNaN(value))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(year, month - 1, day);
|
||||
|
||||
if (
|
||||
parsedDate.getFullYear() !== year ||
|
||||
parsedDate.getMonth() !== month - 1 ||
|
||||
parsedDate.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
};
|
||||
|
||||
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
|
||||
const parts = value.split("-");
|
||||
|
||||
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [first, second, third] = parts.map(Number);
|
||||
|
||||
switch (format) {
|
||||
case "M-d-y":
|
||||
return buildDate(third, first, second);
|
||||
case "d-M-y":
|
||||
return buildDate(third, second, first);
|
||||
case "y-M-d":
|
||||
return buildDate(first, second, third);
|
||||
}
|
||||
};
|
||||
|
||||
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
|
||||
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
|
||||
|
||||
if (isoMatch) {
|
||||
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
|
||||
}
|
||||
|
||||
if (format) {
|
||||
return parseLegacyStoredDateValue(value, format);
|
||||
}
|
||||
|
||||
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
|
||||
return parseLegacyStoredDateValue(value, "d-M-y");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatStoredDateForDisplay = (
|
||||
value: string,
|
||||
format: TSurveyDateElement["format"] | undefined,
|
||||
locale: string = "en-US"
|
||||
): string | null => {
|
||||
const parsedDate = parseStoredDateValue(value, format);
|
||||
|
||||
if (!parsedDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatDateWithOrdinal(parsedDate, locale);
|
||||
};
|
||||
|
||||
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
|
||||
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
|
||||
if (element.type === "date") {
|
||||
dateFormats[element.id] = element.format;
|
||||
}
|
||||
|
||||
return dateFormats;
|
||||
}, {});
|
||||
};
|
||||
@@ -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(", ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
@@ -174,6 +175,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}}",
|
||||
@@ -213,12 +215,12 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment": "Umgebung",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
@@ -255,11 +257,13 @@
|
||||
"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",
|
||||
"invite_them": "Lade sie ein",
|
||||
"javascript_required": "JavaScript erforderlich",
|
||||
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
|
||||
"key": "Schlüssel",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
@@ -280,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -294,6 +300,7 @@
|
||||
"new": "Neu",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
"next": "Weiter",
|
||||
"no_actions_found": "Keine Aktionen gefunden",
|
||||
"no_background_image_found": "Kein Hintergrundbild gefunden.",
|
||||
"no_code": "No Code",
|
||||
"no_files_uploaded": "Keine Dateien hochgeladen",
|
||||
@@ -319,10 +326,9 @@
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation nicht gefunden",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
@@ -339,6 +345,7 @@
|
||||
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
||||
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
||||
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||
"powered_by_formbricks": "Bereitgestellt von Formbricks",
|
||||
"preview": "Vorschau",
|
||||
"preview_survey": "Umfragevorschau",
|
||||
"privacy": "Datenschutz",
|
||||
@@ -380,6 +387,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",
|
||||
@@ -399,7 +407,7 @@
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.",
|
||||
"sort_by": "Sortieren nach",
|
||||
"start_free_trial": "Kostenlos starten",
|
||||
"start_free_trial": "Kostenlose Testversion starten",
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
@@ -412,7 +420,6 @@
|
||||
"survey_id": "Umfrage-ID",
|
||||
"survey_languages": "Umfragesprachen",
|
||||
"survey_live": "Umfrage live",
|
||||
"survey_not_found": "Umfrage nicht gefunden",
|
||||
"survey_paused": "Umfrage pausiert.",
|
||||
"survey_type": "Umfragetyp",
|
||||
"surveys": "Umfragen",
|
||||
@@ -427,7 +434,6 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -444,13 +450,13 @@
|
||||
"update": "Aktualisierung",
|
||||
"updated": "Aktualisiert",
|
||||
"updated_at": "Aktualisiert am",
|
||||
"upgrade_plan": "Plan upgraden",
|
||||
"upload": "Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
|
||||
"url": "URL",
|
||||
"user": "Benutzer",
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
@@ -466,14 +472,13 @@
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workflows": "Workflows",
|
||||
"workspace": "Arbeitsbereich",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
|
||||
"workspace_id": "Projekt-ID",
|
||||
"workspace_name": "Projektname",
|
||||
"workspace_name_placeholder": "z. B. Formbricks",
|
||||
"workspace_not_found": "Projekt nicht gefunden",
|
||||
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
|
||||
"workspaces": "Projekte",
|
||||
"years": "Jahre",
|
||||
"you": "Du",
|
||||
@@ -658,7 +663,6 @@
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
@@ -849,9 +853,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",
|
||||
@@ -972,30 +983,57 @@
|
||||
},
|
||||
"billing": {
|
||||
"add_payment_method": "Zahlungsmethode hinzufügen",
|
||||
"cancelling": "Wird storniert",
|
||||
"add_payment_method_to_upgrade_tooltip": "Bitte füge oben eine Zahlungsmethode hinzu, um auf einen kostenpflichtigen Plan zu upgraden",
|
||||
"billing_interval_toggle": "Abrechnungsintervall",
|
||||
"current_plan_badge": "Aktuell",
|
||||
"current_plan_cta": "Aktueller Tarif",
|
||||
"custom_plan_description": "Deine Organisation nutzt ein individuelles Abrechnungsmodell. Du kannst trotzdem zu einem der Standardtarife unten wechseln.",
|
||||
"custom_plan_title": "Individueller Tarif",
|
||||
"failed_to_start_trial": "Die Testversion konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"keep_current_plan": "Aktuellen Tarif beibehalten",
|
||||
"manage_billing_details": "Kartendaten & Rechnungen verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"most_popular": "Am beliebtesten",
|
||||
"pending_change_removed": "Geplante Tarifänderung entfernt.",
|
||||
"pending_plan_badge": "Geplant",
|
||||
"pending_plan_change_description": "Dein Tarif wechselt am {{date}} zu {{plan}}.",
|
||||
"pending_plan_change_title": "Geplante Tarifänderung",
|
||||
"pending_plan_cta": "Geplant",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_change_applied": "Tarif erfolgreich aktualisiert.",
|
||||
"plan_change_scheduled": "Tarifänderung erfolgreich geplant.",
|
||||
"plan_custom": "Custom",
|
||||
"plan_feature_everything_in_hobby": "Alles aus Hobby",
|
||||
"plan_feature_everything_in_pro": "Alles aus Pro",
|
||||
"plan_hobby": "Hobby",
|
||||
"plan_hobby_description": "Für Einzelpersonen und kleine Teams, die mit Formbricks Cloud starten.",
|
||||
"plan_hobby_feature_responses": "250 Antworten / Monat",
|
||||
"plan_hobby_feature_workspaces": "1 Arbeitsbereich",
|
||||
"plan_pro": "Pro",
|
||||
"plan_pro_description": "Für wachsende Teams, die höhere Limits, Automatisierungen und dynamische Überschreitungen benötigen.",
|
||||
"plan_pro_feature_responses": "2.000 Antworten / Monat (dynamische Überschreitung)",
|
||||
"plan_pro_feature_workspaces": "3 Arbeitsbereiche",
|
||||
"plan_scale": "Scale",
|
||||
"plan_scale_description": "Für größere Teams, die mehr Kapazität, stärkere Governance und höheres Antwortvolumen benötigen.",
|
||||
"plan_scale_feature_responses": "5.000 Antworten / Monat (dynamische Mehrnutzung)",
|
||||
"plan_scale_feature_workspaces": "5 Arbeitsbereiche",
|
||||
"plan_selection_description": "Vergleiche Hobby, Pro und Scale und wechsle dann direkt in Formbricks den Plan.",
|
||||
"plan_selection_title": "Wähle deinen Plan",
|
||||
"plan_unknown": "Unbekannt",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"retry_setup": "Erneut einrichten",
|
||||
"scale_banner_description": "Schalte höhere Limits, Teamzusammenarbeit und erweiterte Sicherheitsfunktionen mit dem Scale-Tarif frei.",
|
||||
"scale_banner_title": "Bereit für den nächsten Schritt?",
|
||||
"scale_feature_api": "Vollständiger API-Zugang",
|
||||
"scale_feature_quota": "Quotenverwaltung",
|
||||
"scale_feature_spam": "Spamschutz",
|
||||
"scale_feature_teams": "Teams & Zugriffsrollen",
|
||||
"select_plan_header_subtitle": "Keine Kreditkarte erforderlich, keine versteckten Bedingungen.",
|
||||
"select_plan_header_title": "Versende noch heute professionelle Umfragen ohne Branding!",
|
||||
"select_plan_header_title": "Nahtlos integrierte Umfragen, 100% deine Marke.",
|
||||
"status_trialing": "Trial",
|
||||
"stay_on_hobby_plan": "Ich möchte beim Hobby-Plan bleiben",
|
||||
"stripe_setup_incomplete": "Abrechnungseinrichtung unvollständig",
|
||||
"stripe_setup_incomplete_description": "Die Abrechnungseinrichtung war nicht erfolgreich. Bitte versuche es erneut, um Dein Abo zu aktivieren.",
|
||||
"subscription": "Abonnement",
|
||||
"subscription_description": "Verwalte Dein Abonnement und behalte Deine Nutzung im Blick",
|
||||
"switch_at_period_end": "Am Ende der Periode wechseln",
|
||||
"switch_plan_now": "Plan jetzt wechseln",
|
||||
"this_includes": "Das beinhaltet",
|
||||
"trial_alert_description": "Füge eine Zahlungsmethode hinzu, um weiterhin Zugriff auf alle Funktionen zu behalten.",
|
||||
"trial_already_used": "Für diese E-Mail-Adresse wurde bereits eine kostenlose Testversion genutzt. Bitte upgraden Sie stattdessen auf einen kostenpflichtigen Plan.",
|
||||
"trial_feature_api_access": "API-Zugriff",
|
||||
@@ -1013,8 +1051,11 @@
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
"unlimited_workspaces": "Unbegrenzte Projekte",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Jetzt upgraden",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "verwendet",
|
||||
"yearly": "Jährlich",
|
||||
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
|
||||
"your_plan": "Dein Tarif"
|
||||
},
|
||||
"domain": {
|
||||
@@ -1040,6 +1081,25 @@
|
||||
"enterprise_features": "Unternehmensfunktionen",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
|
||||
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
|
||||
"license_feature_audit_logs": "Audit-Protokolle",
|
||||
"license_feature_contacts": "Kontakte & Segmente",
|
||||
"license_feature_projects": "Arbeitsbereiche",
|
||||
"license_feature_quotas": "Kontingente",
|
||||
"license_feature_remove_branding": "Branding entfernen",
|
||||
"license_feature_saml": "SAML SSO",
|
||||
"license_feature_spam_protection": "Spam-Schutz",
|
||||
"license_feature_sso": "OIDC SSO",
|
||||
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
|
||||
"license_feature_whitelabel": "White-Label-E-Mails",
|
||||
"license_features_table_access": "Zugriff",
|
||||
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
|
||||
"license_features_table_disabled": "Deaktiviert",
|
||||
"license_features_table_enabled": "Aktiviert",
|
||||
"license_features_table_feature": "Funktion",
|
||||
"license_features_table_title": "Lizenzierte Funktionen",
|
||||
"license_features_table_unlimited": "Unbegrenzt",
|
||||
"license_features_table_value": "Wert",
|
||||
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
|
||||
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
|
||||
"license_status": "Lizenzstatus",
|
||||
@@ -1361,7 +1421,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",
|
||||
@@ -1399,6 +1458,7 @@
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
|
||||
"everyone": "Jeder",
|
||||
"expand_preview": "Vorschau erweitern",
|
||||
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
@@ -1624,6 +1684,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.",
|
||||
@@ -1652,6 +1714,7 @@
|
||||
"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",
|
||||
@@ -1667,10 +1730,12 @@
|
||||
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
"survey_placement": "Platzierung der Umfrage",
|
||||
"survey_preview": "Umfragevorschau 👀",
|
||||
"survey_styling": "Umfrage Styling",
|
||||
"survey_trigger": "Auslöser der Umfrage",
|
||||
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
|
||||
@@ -3021,7 +3086,7 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nein, danke!",
|
||||
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
|
||||
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
|
||||
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
|
||||
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
|
||||
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
|
||||
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
|
||||
"preview_survey_welcome_card_headline": "Willkommen!",
|
||||
@@ -3276,7 +3341,7 @@
|
||||
"workflows": {
|
||||
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
|
||||
"coming_soon_title": "Wir sind fast da!",
|
||||
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
|
||||
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
|
||||
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
|
||||
"generate_button": "Workflow generieren",
|
||||
"heading": "Welchen Workflow möchtest du erstellen?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user