Compare commits

..

21 Commits

Author SHA1 Message Date
Dhruwang 72d34f3678 refactor: remove organizationId from various actions and components
- Removed organizationId from ZResetSurveyAction, ZUpdateSegmentAction, ZDeleteQuotaAction, ZUpdateInviteAction, and ZDeleteInviteAction schemas.
- Updated corresponding action calls in SurveyAnalysisCTA, SegmentSettings, TargetingCard, and MemberActions components to eliminate organizationId parameter, enhancing security by preventing IDOR vulnerabilities.
2026-03-16 18:15:15 +05:30
Santosh 8c6496cdd4 merge: resolve conflicts with main branch
Merge origin/main into fix/idor-server-actions-and-sentry-v2-logging,
resolving conflicts in 5 server action files by combining:
- .inputSchema() API from main (renamed from .schema())
- IDOR fix: derive organizationId from target resource, not client input
2026-03-11 13:11:41 +01:00
Anshuman Pandey 9df423073f fix: zlib CVE (#7444) 2026-03-11 11:10:29 +00:00
Johannes 3e3c696972 feat: add trigger after time passed (#7452)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-11 10:12:31 +00:00
Anshuman Pandey cb41e2d344 fix: sets apps/web TS strict check to true (#7451) 2026-03-11 10:14:37 +01:00
Santosh fc762ebffc fix: derive organizationId from target resource in updateSegment and quota actions
- updateSegmentAction: use getOrganizationIdFromSegmentId instead of
  getOrganizationIdFromEnvironmentId to prevent IDOR via caller-supplied
  environmentId
- deleteQuotaAction/updateQuotaAction: use getOrganizationIdFromQuotaId
  and getProjectIdFromQuotaId instead of deriving from caller-supplied
  surveyId/quota.surveyId

Addresses review feedback from @BhagyaAmarasinghe on remaining IDOR
vectors in #7326.
2026-03-11 09:18:44 +01:00
Matti Nannt 1e19cca7d9 feat: implement cloud stripe billing sync and pricing revamp (#7309)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 16:04:23 +00:00
Johannes fa882dd4cc fix: improve survey validation error handling in SurveyMenuBar component (#7447) 2026-03-10 10:23:05 +00:00
Matti Nannt 0b82c6de77 feat: move multi-language surveys and workspace languages to AGPL (#7426)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-10 09:28:01 +00:00
Balázs Úr a944d7626e chore: use Unicode punctuation, remove contractions, make wording consistent (#7355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 07:06:30 +00:00
Balázs Úr d1b12dc228 fix: mark strings as translatable in survey editor (#7369)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:14:29 +00:00
Bhagya Amarasinghe 9f7d6038b1 docs: add CDN guidance for self-hosting (#7446)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-10 06:12:36 +00:00
Balázs Úr 1da92addd2 fix: Hungarian translations (#7434)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-09 12:31:24 +00:00
Dhruwang Jariwala 1e4aa5f54b fix: strip inline styles preserve target attr (#7441)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:09:51 +00:00
Anshuman Pandey 96f173c3b1 fix: overrides packages for CVE fixes (#7442) 2026-03-09 09:55:02 +00:00
Harsh Bhat 9c9e55fba6 docs: add keycloack docs (#7440) 2026-03-09 08:38:00 +00:00
Johannes 42541f86fd feat(navigation): add workflows section to main navigation and update… (#7392) 2026-03-08 18:13:38 +00:00
Matti Nannt 0ba469a73d fix: pin fast-xml-parser to 5.3.5 (#7436) 2026-03-06 20:20:34 +01:00
Matti Nannt afa192e5b9 chore: upgrade deps and Zod v4 migration (#7425)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 14:41:28 +01:00
Bhagya Amarasinghe 4860a9a5cf fix: helm template duplicate label key in migration-job (#7431)
Co-authored-by: Rob <178471500+rob-htl@users.noreply.github.com>
2026-03-06 11:48:07 +00:00
Santosh 77f7e099b9 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7326, #6677)
resetSurveyAction, deleteInviteAction, and updateInviteAction accepted
organizationId from client input for authorization while operating on
resources identified by separate IDs. An authenticated user belonging
to multiple organizations could authorize against their own org while
mutating resources in another org.

Derive organizationId from the target resource using existing helpers
(getOrganizationIdFromSurveyId, getOrganizationIdFromInviteId),
matching the pattern already used by adjacent safe actions in the same
files.

Also adds request method and path as Sentry tags and structured log
context in the API v2 error handler, bringing v2 error reporting to
parity with v1.
2026-03-04 12:16:35 +01:00
733 changed files with 16212 additions and 12995 deletions
+3 -1
View File
@@ -150,6 +150,8 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables
STRIPE_PRICING_TABLE_ID=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -230,4 +232,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here
LINGODOTDEV_API_KEY=your_api_key_here
+6 -6
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.14",
"@storybook/addon-links": "10.2.14",
"@storybook/addon-onboarding": "10.2.14",
"@storybook/react-vite": "10.2.14",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.14",
"storybook": "10.2.15",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.14"
"@storybook/addon-docs": "10.2.15"
}
}
+4 -3
View File
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate
@@ -74,9 +74,10 @@ RUN --mount=type=secret,id=database_url \
#
FROM base AS runner
# Update npm to latest, then create user
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN npm install --ignore-scripts -g npm@latest \
RUN apk update && apk upgrade --no-cache \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props) => {
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
elements: [
{
id: "q1",
type: "openText" as const,
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
charLimit: { enabled: true, max: 1000 },
},
],
},
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFunction;
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMSurveyDefault(tMock);
expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
});
test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFunction;
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
const result = getXMTemplates(tMock);
expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFunction;
}) as unknown as TFunction;
const result = getXMTemplates(tMock);
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props) => {
const LandingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props) => {
const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props) => {
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -28,8 +31,10 @@ const OnboardingLayout = async (props) => {
throw new Error(t("common.organization_not_found"));
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`);
@@ -42,7 +42,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
const Icon = option.icon;
return (
<OptionCard
@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.string().cuid2(),
id: z.cuid2(),
name: z.string(),
});
@@ -2,7 +2,10 @@ import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props) => {
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -7,15 +7,36 @@ import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
interface ConfirmationPageProps {
environmentId: string;
environmentId?: string;
}
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState(environmentId ?? null);
useEffect(() => {
setShowConfetti(true);
}, []);
if (globalThis.window === undefined) {
return;
}
if (environmentId) {
globalThis.window.sessionStorage.setItem(BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY, environmentId);
setResolvedEnvironmentId(environmentId);
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, [environmentId]);
return (
<div className="h-full w-full">
@@ -30,7 +51,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link href={`/environments/${environmentId}/settings/billing`}>
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
@@ -3,13 +3,17 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic";
const Page = async (props) => {
const Page = async (props: { searchParams: Promise<{ environmentId: string }> }) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
if (!environmentId) {
return null;
}
return (
<PageContentWrapper>
<ConfirmationPage environmentId={environmentId?.toString()} />
<ConfirmationPage environmentId={environmentId} />
</PageContentWrapper>
);
};
@@ -10,7 +10,6 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
@@ -25,67 +24,63 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
};
],
});
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
const organization = await getOrganization(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
if (!organization) {
throw new Error("Organization not found");
}
)
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
);
const ZGetOrganizationsForSwitcherAction = z.object({
@@ -97,7 +92,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction)
.inputSchema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -122,7 +117,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction)
.inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -29,7 +29,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed,
projectPermission,
license,
peopleCount,
responseCount,
} = layoutData;
@@ -38,7 +37,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members
@@ -52,7 +51,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner
organization={organization}
environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount}
/>
)}
@@ -11,6 +11,7 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -114,14 +115,21 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname]
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props) => {
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);
@@ -4,7 +4,10 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props) => {
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
@@ -4,7 +4,6 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZUpdateNotificationSettingsAction = z.object({
@@ -12,26 +11,16 @@ const ZUpdateNotificationSettingsAction = z.object({
});
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.inputSchema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);
@@ -16,8 +16,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
const newNotificationSettings: TUserNotificationSettings = {
alert: {} as Record<string, boolean>,
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
@@ -26,7 +26,8 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished ||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
@@ -136,7 +137,10 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships;
};
const Page = async (props) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -20,7 +20,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -63,50 +63,36 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
)
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
);
@@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
@@ -11,7 +11,7 @@ import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -7,7 +7,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,49 +16,38 @@ const ZUpdateOrganizationNameAction = z.object({
});
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
@@ -77,6 +65,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
})
);
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState("");
const { t } = useTranslation();
const handleInputChange = (e) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
}
};
@@ -26,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props) => {
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
const params = await props.params;
const { children } = props;
@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }) => {
export const SettingsTitle = ({ title }: { title: string }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
};
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
});
export const getResponsesAction = authenticatedActionClient
.schema(ZGetResponsesAction)
.inputSchema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
});
export const getSurveySummaryAction = authenticatedActionClient
.schema(ZGetSurveySummaryAction)
.inputSchema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
});
export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction)
.inputSchema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -27,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
};
};
const SurveyLayout = async ({ children }) => {
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
};
@@ -205,11 +205,11 @@ export const ResponseTable = ({
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
format,
filterCriteria: { responseIds },
});
@@ -5,7 +5,7 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -41,7 +41,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => (
<div className="flex items-center justify-between">
@@ -232,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label];
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
metadataColumns.push({
accessorKey: "METADATA_" + label,
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -38,7 +39,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source",
};
return translations[key] || key;
});
}) as unknown as TFunction;
describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => {
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
if (userAgentFields.includes(label)) {
return meta.userAgent?.[label];
export const getMetadataValue = (
meta: TResponseMeta,
label: (typeof METADATA_FIELDS)[number]
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
}
return meta[label];
};
@@ -17,7 +17,7 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
@@ -27,7 +27,7 @@ const Page = async (props) => {
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
@@ -53,7 +53,7 @@ const Page = async (props) => {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -7,7 +7,6 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
});
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction)
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
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);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
);
const ZGetEmailHtmlAction = z.object({
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
});
export const getEmailHtmlAction = authenticatedActionClient
.schema(ZGetEmailHtmlAction)
.inputSchema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.inputSchema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
},
};
const filter = filters[group];
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
group
];
if (filter) {
setFilter(
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean;
}
const formatTime = (ttc) => {
const formatTime = (ttc: number) => {
const seconds = ttc / 1000;
let formattedValue;
@@ -105,7 +105,7 @@ export const SummaryPage = ({
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
toast.error(error instanceof Error ? error.message : String(error));
setDisplays([]);
setHasMoreDisplays(false);
} finally {
@@ -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) {
@@ -75,17 +75,7 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslation();
const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const linkTabs = useMemo(() => {
const tabs = [
{
id: ShareViaType.ANON_LINKS,
@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
});
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
}
}
} catch (error) {
logger.error("Failed to generate QR code:", error);
logger.error(error as Error, "Failed to generate QR code");
setHasError(true);
} finally {
setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) {
logger.error("Failed to download QR code:", error);
logger.error(error as Error, "Failed to download QR code");
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally {
setIsDownloading(false);
@@ -4,6 +4,10 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge";
@@ -13,9 +17,9 @@ interface SuccessViewProps {
publicDomain: string;
setSurveyUrl: (url: string) => void;
user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
isReadOnly: boolean;
}
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: {
quotaLinks: 0,
},
},
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
]);
const result = await getQuotasSummary(surveyId);
@@ -662,17 +662,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(2);
expect(item1.avgRanking).toBe(1.5);
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(2);
expect(item2.avgRanking).toBe(1.5);
// Item 3 is in position 3 twice, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3);
});
@@ -747,17 +753,23 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1);
// Item 1 is in position 2, so avg ranking should be 2
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(2);
// Item 2 is in position 1, so avg ranking should be 1
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(1);
expect(item2.avgRanking).toBe(1);
// Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -830,10 +842,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -894,10 +908,12 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
});
(summary[0] as any).choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.avgRanking).toBe(0);
}
);
});
test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -958,17 +974,23 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1, so avg ranking should be 1
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
const item1 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(1);
// Item 2 was not ranked, so should have count 0 and avgRanking 0
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
const item2 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(0);
expect(item2.avgRanking).toBe(0);
// Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
const item3 = (summary[0] as any).choices.find(
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3);
});
@@ -986,7 +1008,11 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1020,8 +1046,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
.filter((r: Record<string, unknown>) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1043,7 +1069,11 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
mockResponses.map((r: Record<string, unknown>) => ({
...r,
contactId: null,
personAttributes: {},
})) as any
);
// React cache is already mocked globally - no need to mock it again
});
@@ -1842,23 +1872,63 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Verify Speed row
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(2);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(50);
// Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2);
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
.percentage
).toBe(50);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
});
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -1949,19 +2019,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// Verify Speed row with localized values mapped to default language
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Verify Quality row
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
// Verify Price row
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
});
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2055,12 +2154,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2147,22 +2252,59 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Verify Speed row - both responses provided data
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1);
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
// Verify Price row - both responses provided data
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
});
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2221,12 +2363,18 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach((row) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
});
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2296,21 +2444,46 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1);
// Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no valid responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col) => {
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
// Price row should have a valid response
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
const priceRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1);
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
});
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2381,17 +2554,48 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows
// Speed row should have a valid response
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "InvalidRow"
)
).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "AnotherInvalidRow"
)
).toBeUndefined();
});
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2493,12 +2697,27 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
const speedRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
expect(
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
const qualityRow = summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0);
});
@@ -2557,12 +2776,18 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses
summary[0].data.forEach((row) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0);
});
});
summary[0].data.forEach(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => {
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
});
});
@@ -2994,23 +3219,33 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25);
// Verify each rating option count and percentage
const rating5 = summary[0].choices.find((c) => c.rating === 5);
const rating5 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
);
expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find((c) => c.rating === 4);
const rating4 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find((c) => c.rating === 3);
const rating3 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find((c) => c.rating === 2);
const rating2 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find((c) => c.rating === 1);
const rating1 = summary[0].choices.find(
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0);
@@ -3154,10 +3389,12 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
@@ -3232,15 +3469,21 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// Check individual choice counts
const img1 = summary[0].choices.find((c) => c.id === "img1");
const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find((c) => c.id === "img2");
const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find((c) => c.id === "img3");
const img3 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50);
});
@@ -3311,10 +3554,12 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count
summary[0].choices.forEach((choice) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
summary[0].choices.forEach(
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
}
);
});
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3373,17 +3618,23 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// img1 should be counted
const img1 = summary[0].choices.find((c) => c.id === "img1");
const img1 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1);
expect(img1.percentage).toBe(100);
// img2 should not be counted
const img2 = summary[0].choices.find((c) => c.id === "img2");
const img2 = summary[0].choices.find(
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(0);
expect(img2.percentage).toBe(0);
// Invalid ID should not appear in choices
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
expect(
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
});
});
@@ -14,11 +14,7 @@ import {
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryAddress,
@@ -293,7 +289,10 @@ const checkForI18n = (
) => {
const element = elements.find((element) => element.id === id);
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
if (
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
// Initialize an array to hold the choice values
let choiceValues = [] as string[];
@@ -318,13 +317,9 @@ const checkForI18n = (
}
// Return the localized value of the choice fo multiSelect single element
if (element && "choices" in element) {
const choice = element.choices?.find(
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
}
return responseData[id];
@@ -832,13 +827,19 @@ export const getElementSummary = async (
let totalResponseCount = 0;
// Initialize count object
const countMap: Record<string, string> = rows.reduce((acc, row) => {
acc[row] = columns.reduce((colAcc, col) => {
colAcc[col] = 0;
return colAcc;
}, {});
return acc;
}, {});
const countMap: Record<string, Record<string, number>> = rows.reduce(
(acc: Record<string, Record<string, number>>, row) => {
acc[row] = columns.reduce(
(colAcc: Record<string, number>, col) => {
colAcc[col] = 0;
return colAcc;
},
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>;
@@ -1095,7 +1096,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[cursor, z.cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
@@ -40,10 +40,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) {
throw new Error(t("common.user_not_found"));
}
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
@@ -51,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -4,18 +4,16 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
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 { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
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 { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -28,7 +26,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.schema(ZGetResponsesDownloadUrlAction)
.inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -58,7 +56,7 @@ const ZGetSurveyFilterDataAction = z.object({
});
export const getSurveyFilterDataAction = authenticatedActionClient
.schema(ZGetSurveyFilterDataAction)
.inputSchema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
@@ -89,7 +87,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
@@ -115,60 +113,52 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
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",
},
],
});
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 { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(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;
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;
})
);
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const datePickerRef = useRef<HTMLDivElement>(null);
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
let keys: string[] = [];
for (let key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
} else {
keys.push(parentKey + key);
}
@@ -113,7 +113,9 @@ const elementIcons = {
};
const getIcon = (type: string) => {
const IconComponent = elementIcons[type];
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
type
];
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
};
@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
setFilterValue({ ...filterValue });
};
const handleRemoveMultiSelect = (value: string[], index) => {
const handleRemoveMultiSelect = (value: string[], index: number) => {
filterValue.filter[index] = {
...filterValue.filter[index],
filterType: {
@@ -34,23 +34,27 @@ export const SurveyStatusDropdown = ({
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
if (updateSurveyActionResponse?.data) {
toast.success(
status === "inProgress"
? t("common.survey_live")
: status === "paused"
? t("common.survey_paused")
: status === "completed"
? t("common.survey_completed")
: ""
);
const resultingStatus = updateSurveyActionResponse.data.status;
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
inProgress: t("common.survey_live"),
paused: t("common.survey_paused"),
completed: t("common.survey_completed"),
};
const toastMessage = statusToToastMessage[resultingStatus];
if (toastMessage) {
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage);
}
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
};
return (
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};
@@ -0,0 +1,208 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -0,0 +1,42 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -6,7 +6,6 @@ import { ZIntegrationInput } from "@formbricks/types/integration";
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
@@ -21,58 +20,10 @@ const ZCreateOrUpdateIntegrationAction = z.object({
});
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.inputSchema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
"integration",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(
parsedInput.environmentId,
parsedInput.integrationData
);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -84,17 +35,48 @@ export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteI
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
})
);
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
withAuditLogging("deleted", "integration", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
})
);
@@ -284,7 +284,7 @@ export const AddIntegrationModal = ({
}
handleClose();
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
};
@@ -322,7 +322,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
};
@@ -13,7 +13,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
@@ -17,7 +17,7 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -51,7 +51,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
});
export const getSpreadsheetNameByIdAction = authenticatedActionClient
.schema(ZGetSpreadsheetNameByIdAction)
.inputSchema(ZGetSpreadsheetNameByIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -195,7 +195,7 @@ export const AddIntegrationModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingSheet(false);
}
@@ -237,7 +237,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}
@@ -16,7 +16,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
@@ -234,7 +234,7 @@ export const AddIntegrationModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingDatabase(false);
}
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}
@@ -65,7 +65,7 @@ const MappingErrorMessage = ({
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
mapped_type: (TYPE_MAPPING as Record<string, string[]>)[element.id].join(" ,"),
})}
</>
);
@@ -135,7 +135,7 @@ export const MappingRow = ({
return copy;
}
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
const isValidColType = (TYPE_MAPPING as Record<string, string[]>)[item.type]?.includes(col.type);
if (!isValidColType) {
copy[idx] = {
...copy[idx],
@@ -166,7 +166,7 @@ export const MappingRow = ({
return copy;
}
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
const isValidElemType = (TYPE_MAPPING as Record<string, string[]>)[elem.type]?.includes(item.type);
if (!isValidElemType) {
copy[idx] = {
...copy[idx],
@@ -36,7 +36,7 @@ export const NotionWrapper = ({
}: NotionWrapperProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnected, setIsConnected] = useState(
notionIntegration ? notionIntegration.config.key?.bot_id : false
notionIntegration ? !!notionIntegration.config.key?.bot_id : false
);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationNotionConfigData & { index: number }) | null
@@ -18,7 +18,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const enabled = !!(
@@ -27,7 +27,7 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
return `${count} ${type}s`;
};
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
});
export const getSlackChannelsAction = authenticatedActionClient
.schema(ZGetSlackChannelsAction)
.inputSchema(ZGetSlackChannelsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -161,7 +161,7 @@ export const AddChannelMappingModal = ({
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
} finally {
setIsLinkingChannel(false);
}
@@ -200,7 +200,7 @@ export const AddChannelMappingModal = ({
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsDeleting(false);
}
@@ -30,7 +30,7 @@ export const SlackWrapper = ({
webAppUrl,
locale,
}: SlackWrapperProps) => {
const [isConnected, setIsConnected] = useState(slackIntegration ? slackIntegration.config?.key : false);
const [isConnected, setIsConnected] = useState(slackIntegration ? !!slackIntegration.config?.key : false);
const [slackChannels, setSlackChannels] = useState<TIntegrationItem[]>([]);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
@@ -11,7 +11,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props) => {
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
@@ -1,3 +1,3 @@
import { LanguagesLoading } from "@/modules/ee/languages/loading";
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;
@@ -1,3 +1,3 @@
import { LanguagesPage } from "@/modules/ee/languages/page";
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;
+1 -1
View File
@@ -7,7 +7,7 @@ import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => {
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;
+1 -1
View File
@@ -1,6 +1,6 @@
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
const AppLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<>
<NoMobileOverlay />
@@ -169,6 +169,9 @@ export const mockSurvey: TSurvey = {
segment: null,
followUps: mockFollowUps,
metadata: {},
blocks: [],
isCaptureIpEnabled: false,
slug: null,
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {
@@ -156,7 +156,7 @@ const handleAirtableIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -197,7 +197,7 @@ const handleGoogleSheetsIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -239,7 +239,7 @@ const handleSlackIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -349,7 +349,7 @@ const handleNotionIntegration = async (
} catch (err) {
return {
ok: false,
error: err,
error: err instanceof Error ? err : new Error(String(err)),
};
}
};
@@ -374,8 +374,8 @@ const buildNotionPayloadProperties = (
const pictureElement = surveyElements.find((el) => el.id === resp);
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.filter((choice: { id: string; imageUrl: string }) => selectedChoiceIds.includes(choice.id))
.map((choice: { id: string; imageUrl: string }) => resolveStorageUrlAuto(choice.imageUrl));
}
});
@@ -6,7 +6,12 @@ import { logger } from "@formbricks/logger";
import { sendTelemetryEvents } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/cache");
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
createCacheKey: {
custom: vi.fn((_namespace: string, key: string) => key),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
@@ -1,5 +1,5 @@
import { IntegrationType } from "@prisma/client";
import { type CacheKey, getCacheService } from "@formbricks/cache";
import { createCacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { env } from "@/lib/env";
@@ -7,8 +7,8 @@ import { getInstanceInfo } from "@/lib/instance";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
const TELEMETRY_LOCK_KEY = createCacheKey.custom("analytics", "telemetry_lock");
const TELEMETRY_LAST_SENT_KEY = createCacheKey.custom("analytics", "telemetry_last_sent_ts");
/**
* In-memory timestamp for the next telemetry check.
@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
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";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
@@ -290,6 +291,14 @@ export const POST = async (request: Request) => {
});
}
if (event === "responseCreated") {
recordResponseCreatedMeterEvent({
stripeCustomerId: organization.billing.stripeCustomerId,
responseId: response.id,
createdAt: response.createdAt,
}).catch((error) => {
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
});
// Send telemetry events
await sendTelemetryEvents();
}
+16 -2
View File
@@ -1,5 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import NextAuth from "next-auth";
import NextAuth, { Account, Profile, User } from "next-auth";
import { AdapterUser } from "next-auth/adapters";
import { CredentialInput } from "next-auth/providers/credentials";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
@@ -73,7 +75,19 @@ const handler = async (req: Request, ctx: any) => {
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
async signIn({
user,
account,
profile,
email,
credentials,
}: {
user: User | AdapterUser;
account: Account | null;
profile?: Profile;
email?: { verificationRequest?: boolean };
credentials?: Record<string, CredentialInput>;
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
@@ -8,7 +8,9 @@ import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({
@@ -1,19 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -25,7 +19,7 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
@@ -44,7 +38,8 @@ export const POST = withV1ApiWrapper({
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
@@ -38,13 +38,6 @@ const mockEnvironmentData = {
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
@@ -114,13 +107,6 @@ describe("getEnvironmentStateData", () => {
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
@@ -154,18 +140,6 @@ describe("getEnvironmentStateData", () => {
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
@@ -283,32 +257,11 @@ describe("getEnvironmentStateData", () => {
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
test("should not include organization in result", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
expect(result).not.toHaveProperty("organization");
});
});
@@ -25,10 +25,6 @@ export interface EnvironmentStateData {
appSetupCompleted: boolean;
project: TJsEnvironmentStateProject;
};
organization: {
id: string;
billing: any;
};
surveys: TJsEnvironmentStateSurvey[];
actionClasses: TJsEnvironmentStateActionClass[];
}
@@ -59,13 +55,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
placement: true,
inAppSurveyBranding: true,
styling: true,
// Organization data (nested select for efficiency)
organization: {
select: {
id: true,
billing: true,
},
},
},
},
// Action classes (optimized for environment state)
@@ -157,10 +146,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
if (!environmentData.project.organization) {
throw new ResourceNotFoundError("organization", null);
}
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
@@ -181,10 +166,6 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: resolveStorageUrlsInObject(environmentData.project.styling),
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
@@ -1,18 +1,15 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
@@ -70,17 +67,14 @@ const mockOrganization: TOrganization = {
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
stripeCustomerId: null,
period: "monthly",
limits: {
projects: 1,
monthly: {
responses: 100,
miu: 1000,
},
},
periodStart: new Date(),
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
};
@@ -162,7 +156,6 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});
afterEach(() => {
@@ -182,7 +175,6 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -221,24 +213,6 @@ describe("getEnvironmentState", () => {
expect(result.data).toBeDefined();
});
test("should return empty surveys if monthly response limit reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(99); // Below limit
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
const result = await getEnvironmentState(environmentId);
@@ -255,32 +229,6 @@ describe("getEnvironmentState", () => {
);
});
test("should handle null response limit correctly (unlimited)", async () => {
const unlimitedOrgData = {
...mockEnvironmentStateData,
organization: {
...mockEnvironmentStateData.organization,
billing: {
...mockOrganization.billing,
limits: {
...mockOrganization.billing.limits,
monthly: {
...mockOrganization.billing.limits.monthly,
responses: null, // Unlimited
},
},
},
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(unlimitedOrgData);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(999999); // High count
const result = await getEnvironmentState(environmentId);
// Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys);
});
test("should propagate database update errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
@@ -3,8 +3,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getEnvironmentStateData } from "./data";
/**
@@ -22,8 +21,7 @@ export const getEnvironmentState = async (
return cache.withCache(
async () => {
// Single optimized database call replacing multiple service calls
const { environment, organization, surveys, actionClasses } =
await getEnvironmentStateData(environmentId);
const { environment, surveys, actionClasses } = await getEnvironmentStateData(environmentId);
// Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration
@@ -34,18 +32,9 @@ export const getEnvironmentState = async (
});
}
// Check monthly response limits for Formbricks Cloud
let isMonthlyResponsesLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
}
// Build the response data
const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
surveys,
actionClasses,
project: environment.project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
@@ -54,6 +43,6 @@ export const getEnvironmentState = async (
return { data };
},
createCacheKey.environment.state(environmentId),
60 * 1000 // 1 minutes in milliseconds
60 * 1000 // 1 minute in milliseconds
);
};
@@ -1,10 +1,9 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
@@ -19,13 +18,7 @@ export const OPTIONS = async (): Promise<Response> => {
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
try {
@@ -50,7 +43,7 @@ export const GET = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
);
@@ -102,7 +95,10 @@ export const GET = withV1ApiWrapper({
"Error in GET /api/v1/client/[environmentId]/environment"
);
return {
response: responses.internalServerErrorResponse(err.message, true),
response: responses.internalServerErrorResponse(
err instanceof Error ? err.message : "Unknown error occurred",
true
),
};
}
},
@@ -1,11 +1,11 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -63,13 +63,7 @@ const validateResponse = (
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
}) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
const { responseId } = params;
@@ -98,7 +92,18 @@ export const PUT = withV1ApiWrapper({
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
@@ -115,7 +120,18 @@ export const PUT = withV1ApiWrapper({
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
@@ -128,7 +144,7 @@ export const PUT = withV1ApiWrapper({
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
@@ -173,6 +189,14 @@ export const PUT = withV1ApiWrapper({
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;
@@ -19,7 +19,7 @@ vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
cache: vi.fn((fn: Function) => fn), // Mock react's cache to just return the function
};
});
@@ -34,7 +34,12 @@ describe("Contact API Lib", () => {
describe("getContact", () => {
test("should return contact if found", async () => {
const mockContactData = { id: mockContactId };
const mockContactData = {
id: mockContactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
};
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
const contact = await getContact(mockContactId);
@@ -77,12 +82,17 @@ describe("Contact API Lib", () => {
test("should return contact with formatted attributes if found", async () => {
const mockContactData = {
id: mockContactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
attributes: [
{ attributeKey: { key: "userId" }, value: mockUserId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData);
vi.mocked(prisma.contact.findFirst).mockResolvedValue(
mockContactData as Awaited<ReturnType<typeof prisma.contact.findFirst>>
);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
@@ -54,10 +54,10 @@ export const getContactByUserId = reactCache(
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
}, {});
return {
id: contact.id,
@@ -63,7 +63,6 @@ const mockOrganization = {
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
@@ -115,7 +114,7 @@ describe("createResponse", () => {
test("should handle finished response and calculate TTC", async () => {
const finishedInput = { ...mockResponseInput, finished: true };
await createResponse(finishedInput);
await createResponse(finishedInput, prisma);
expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -135,13 +134,13 @@ describe("createResponse", () => {
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(genericError);
});
});
@@ -170,7 +169,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: undefined,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
@@ -224,7 +223,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(evaluateResponseQuotas).toHaveBeenCalledWith({
surveyId: mockResponseInput.surveyId,
@@ -278,7 +277,7 @@ describe("createResponseWithQuotaEvaluation", () => {
quotaFull: mockQuotaFull,
});
const result = await createResponseWithQuotaEvaluation(mockResponseInput, mockTx);
const result = await createResponseWithQuotaEvaluation(mockResponseInput);
expect(result).toEqual({
id: responseId,
@@ -1,5 +1,4 @@
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
@@ -9,22 +8,17 @@ import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -56,7 +50,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
@@ -66,7 +60,7 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error.message },
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
),
};
@@ -108,7 +102,8 @@ export const POST = withV1ApiWrapper({
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
@@ -183,7 +178,9 @@ export const POST = withV1ApiWrapper({
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(error.message),
response: responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
),
};
}
}
@@ -1,9 +1,8 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
@@ -12,12 +11,6 @@ import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/li
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -34,7 +27,7 @@ export const OPTIONS = async (): Promise<Response> => {
// use this to let users upload files to a file upload question response for example
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
@@ -96,7 +89,7 @@ export const POST = withV1ApiWrapper({
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
: MAX_FILE_UPLOAD_SIZES.standard;

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