Compare commits

..

18 Commits

Author SHA1 Message Date
Cursor Agent d14805d847 Refactor: Adjust font weight for NPS summary counts and percentages
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 12:36:32 +00:00
Cursor Agent a7ec311147 Refactor rating summary component to handle no responses and improve UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 11:59:40 +00:00
Cursor Agent 712be0ec71 Refactor rating summary component for better UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 11:03:18 +00:00
Cursor Agent 8330e072dd Refactor: Improve rating display and styling
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:53:18 +00:00
Cursor Agent db4bd8966d Refactor: Update rating summary text color to slate-900
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:39:36 +00:00
Cursor Agent a1c65cc60e Refactor: Improve survey summary component styling and layout
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:37:58 +00:00
Cursor Agent 2d3a38c818 Refactor NPS and Rating summary components for better UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:48:28 +00:00
Cursor Agent f0252ed30f Refactor: Improve visual feedback for survey analysis components
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:43:44 +00:00
Cursor Agent acaeeeff07 Refactor SatisfactionSmiley to use a simple div with color classes
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:41:58 +00:00
Cursor Agent 8dfdb084aa feat: Add tabs to survey summary components
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:27:35 +00:00
Cursor Agent 97a48e5469 Refactor NPS summary and update smiley icons and translations
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:21:35 +00:00
Cursor Agent bb3ff3a2c5 Refactor: Add tbody to RatingSmiley table for better structure
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:16:46 +00:00
Cursor Agent 78a6d73ae8 Refactor: Remove unused range variable in survey summary
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:13:34 +00:00
Cursor Agent 21ff1eb385 feat: Add NPS and Rating summary view improvements
This commit introduces several enhancements to the NPS and Rating survey summary views.

**NPS Summary:**
- Implemented a tabbed interface to switch between "grouped" and "individual" views.
- The "grouped" view displays the breakdown of promoters, passives, and detractors.
- The "individual" view shows a bar chart of responses for each NPS score (0-10).
- Added tooltips for better user guidance.

**Rating Summary:**
- Integrated CSAT (Customer Satisfaction) score calculation and display.
- Added tooltips for CSAT information.

**Backend/Logic Changes:**
- Updated `getQuestionSummary` to calculate and include CSAT data for Rating questions.
- Modified `getQuestionSummary` to include an array of `choices` for NPS questions, detailing individual score counts and percentages.
- Added new tests to cover CSAT calculations for various rating scales and NPS individual score breakdowns.
- Updated type definitions for `TSurveyQuestionSummaryRating` and `TSurveyQuestionSummaryNps` to accommodate the new data structures.

Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 08:43:56 +00:00
Matti Nannt 71a85c7126 feat: add CUID v1 validation for environment ID endpoints (#6827)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-17 07:33:52 +00:00
Dhruwang Jariwala 341e2639e1 feat: spanish translations (#6817)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-13 14:48:37 +00:00
Dhruwang Jariwala 056470e6f0 fix: added variable key id mapping UI (#6814) 2025-11-13 09:56:42 +00:00
Dhruwang Jariwala e965ad4b97 fix: raw html issues (#6813) 2025-11-13 09:12:39 +00:00
422 changed files with 4512 additions and 75740 deletions
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";
@@ -1,8 +1,8 @@
"use server";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "./organization";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getWebhookCountBySource } from "./webhook";
@@ -1,6 +1,6 @@
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -13,6 +13,7 @@ import {
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -122,7 +123,7 @@ export const AddIntegrationModal = ({
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
name: getTextContent(getLocalizedValue(q.headline, "default")),
type: q.type,
}))
: [];
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
@@ -1,6 +1,6 @@
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { User } from "@formbricks/database/generated/client";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
@@ -1,5 +1,7 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -9,8 +11,11 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
@@ -26,6 +31,8 @@ interface NPSSummaryProps {
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -61,38 +68,121 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.promotersTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((choice) => {
// Calculate opacity: 0-6 detractors (low opacity), 7-8 passives (medium), 9-10 promoters (high)
const opacity =
choice.rating <= 6
? 0.3 + (choice.rating / 6) * 0.3 // 0.3 to 0.6
: choice.rating <= 8
? 0.6 + ((choice.rating - 6) / 2) * 0.2 // 0.6 to 0.8
: 0.8 + ((choice.rating - 8) / 2) * 0.2; // 0.8 to 1.0
return (
<button
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-medium text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</button>
);
})}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
@@ -1,7 +1,7 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -13,7 +13,10 @@ import {
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
@@ -29,6 +32,8 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -42,52 +47,182 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.csatTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
) : (
<>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
// Calculate opacity based on rating position (higher rating = higher opacity)
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.7; // Range from 30% to 100%
return (
<button
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full ${index === 0 ? "rounded-tl-lg" : ""} ${
index === questionSummary.choices.length - 1 ? "rounded-tr-lg" : ""
} bg-brand-dark`}
style={{ opacity }}
/>
</button>
);
})}
</div>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={false}
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-3 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-5 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</div>
</TabsContent>
</Tabs>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
@@ -0,0 +1,18 @@
interface SatisfactionSmileyProps {
percentage: number;
className?: string;
}
export const SatisfactionSmiley = ({ percentage, className }: SatisfactionSmileyProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass} ${className || ""}`} />;
};
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";
@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
@@ -2334,6 +2334,98 @@ describe("NPS question type tests", () => {
// Score should be -100 since all valid responses are detractors
expect(summary[0].score).toBe(-100);
});
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "nps-q1": 0 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "nps-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "nps-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "nps-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r5", data: { "nps-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// Verify specific scores
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
expect(score0.count).toBe(1);
expect(score0.percentage).toBe(20); // 1/5 * 100
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(score5.count).toBe(1);
expect(score5.percentage).toBe(20);
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
expect(score7.count).toBe(1);
expect(score7.percentage).toBe(20);
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
expect(score9.count).toBe(1);
expect(score9.percentage).toBe(20);
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
expect(score10.count).toBe(1);
expect(score10.percentage).toBe(20);
// Verify scores with no responses have 0 count
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(score1.count).toBe(0);
expect(score1.percentage).toBe(0);
});
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// All scores should have 0 count and percentage
summary[0].choices.forEach((choice: any) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
});
});
describe("Rating question type tests", () => {
@@ -2557,6 +2649,315 @@ describe("Rating question type tests", () => {
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 3,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 3: satisfied = score 3
// 2 out of 3 responses are satisfied (score 3)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
});
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 4,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 4: satisfied = scores 3-4
// 2 out of 3 responses are satisfied (scores 3 and 4)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// 2 out of 3 responses are satisfied (scores 4 and 5)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 6,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 6: satisfied = scores 5-6
// 2 out of 3 responses are satisfied (scores 5 and 6)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 7: satisfied = scores 6-7
// 2 out of 3 responses are satisfied (scores 6 and 7)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 10,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 8 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 10: satisfied = scores 8-10
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(75);
});
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// All 2 responses are satisfied
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 1 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// None of the responses are satisfied (all are 1, 2, or 3)
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
});
describe("PictureSelection question type tests", () => {
@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
@@ -539,6 +539,24 @@ export const getQuestionSummary = async (
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: question.type,
question,
@@ -548,6 +566,10 @@ export const getQuestionSummary = async (
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
@@ -563,10 +585,17 @@ export const getQuestionSummary = async (
score: 0,
};
// Track individual score counts (0-10)
const scoreCountMap: Record<number, number> = {};
for (let i = 0; i <= 10; i++) {
scoreCountMap[i] = 0;
}
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
scoreCountMap[value]++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
@@ -585,6 +614,13 @@ export const getQuestionSummary = async (
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
// Build choices array with individual score breakdown
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
rating: parseInt(rating),
count,
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
}));
summary.push({
type: question.type,
question,
@@ -607,6 +643,7 @@ export const getQuestionSummary = async (
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
choices,
});
break;
}
@@ -1,6 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { PipelineTriggers, Webhook } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Organization } from "@formbricks/database/generated/client";
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -1,5 +1,6 @@
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";
@@ -28,15 +29,38 @@ export const GET = withV1ApiWrapper({
const params = await props.params;
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
// Basic type check for environmentId
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const environmentState = await getEnvironmentState(environmentId);
const { data } = environmentState;
return {
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -78,7 +78,10 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
export const createResponse = async (responseInput: TResponseInput, tx: any): Promise<TResponse> => {
export const createResponse = async (
responseInput: TResponseInput,
tx: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@formbricks/database/generated/client";
import { Prisma } from "@prisma/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TResponseInput } from "@formbricks/types/responses";
@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
@@ -16,7 +16,7 @@ export const GET = withV1ApiWrapper({
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds as string[]);
const actionClasses = await getActionClasses(environmentIds);
return {
response: responses.successResponse(actionClasses),
@@ -1,6 +1,6 @@
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization, Prisma, Response as ResponsePrisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -88,9 +88,10 @@ export const createResponseWithQuotaEvaluation = async (
return txResponse;
};
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInput, tx?: any): Promise<TResponse> => {
export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
@@ -49,11 +49,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(
environmentIds as string[],
limit,
offset
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
}
return {
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
@@ -27,7 +27,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds as string[], limit, offset);
const surveys = await getSurveys(environmentIds, limit, offset);
return {
response: responses.successResponse(surveys),
@@ -1,7 +1,7 @@
import { Prisma, Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { deleteWebhook, getWebhook } from "./webhook";
@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -1,7 +1,7 @@
import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma, WebhookSource } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma, Webhook } from "@formbricks/database/generated/client";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
+1 -1
View File
@@ -13,7 +13,7 @@ export const GET = withV1ApiWrapper({
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds as string[]);
const webhooks = await getWebhooks(environmentIds);
return {
response: responses.successResponse(webhooks),
};
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { getOrganizationBillingByEnvironmentId } from "./organization";
@@ -1,6 +1,6 @@
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
export const getOrganizationBillingByEnvironmentId = reactCache(
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
@@ -86,9 +86,10 @@ const buildPrismaResponseData = (
};
};
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const createResponse = async (responseInput: TResponseInputV2, tx?: any): Promise<TResponse> => {
export const createResponse = async (
responseInput: TResponseInputV2,
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
@@ -1,5 +1,5 @@
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Organization } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
-13
View File
@@ -25,10 +25,6 @@ export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
export type TSessionAuthentication = Session | null;
// Helper type to properly narrow NonNullable<TApiKeyAuthentication> to TAuthenticationApiKey
// This ensures TypeScript properly infers nested properties like environmentPermissions
export type TNonNullableApiKeyAuthentication = NonNullable<TApiKeyAuthentication> & TAuthenticationApiKey;
// Interface for handler function parameters
export interface THandlerParams<TProps = unknown> {
req?: NextRequest;
@@ -276,15 +272,6 @@ const getRouteType = (
*
*/
export const withV1ApiWrapper: {
// More specific overload for TAuthenticationApiKey (non-null) - must come first for proper type inference
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication: TAuthenticationApiKey }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
+1 -1
View File
@@ -1,5 +1,5 @@
import { PipelineTriggers } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { TResponse } from "@formbricks/types/responses";
import { TPipelineInput } from "@/app/lib/types/pipelines";
+1 -1
View File
@@ -1,4 +1,4 @@
import { PipelineTriggers } from "@formbricks/database/generated/client";
import { PipelineTriggers } from "@prisma/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {
+12 -1
View File
@@ -7,7 +7,18 @@
},
"locale": {
"source": "en-US",
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "nl-NL"]
"targets": [
"de-DE",
"fr-FR",
"ja-JP",
"pt-BR",
"pt-PT",
"ro-RO",
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
]
},
"version": 1.8
}
+1
View File
@@ -398,6 +398,7 @@ checksums:
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
+1 -1
View File
@@ -1,6 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
import { PrismaClient } from "@formbricks/database/generated/client";
export const prisma = mockDeep<PrismaClient>();
+1 -1
View File
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
+1 -1
View File
@@ -1,9 +1,9 @@
"use server";
import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ActionClass, Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@formbricks/database/generated/client";
import { Prisma } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
+1
View File
@@ -175,6 +175,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
];
// Billing constants
+2 -4
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
@@ -42,9 +42,7 @@ export const getDisplayCountBySurveyId = reactCache(
}
);
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const deleteDisplay = async (displayId: string, tx?: any): Promise<TDisplay> => {
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {
const prismaClient = tx ?? prisma;
+1 -1
View File
@@ -9,9 +9,9 @@ import {
mockSurveyId,
} from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "./auth";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
+1 -1
View File
@@ -1,6 +1,6 @@
import { EnvironmentType, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EnvironmentType, Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";
+1 -1
View File
@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import type {
+1 -1
View File
@@ -1,6 +1,6 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { Prisma } from "@formbricks/database/generated/client";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
+26
View File
@@ -138,6 +138,7 @@ export const appLanguages = [
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
},
},
{
@@ -153,6 +154,7 @@ export const appLanguages = [
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
},
},
{
@@ -168,6 +170,7 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
},
},
{
@@ -183,6 +186,7 @@ export const appLanguages = [
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
},
},
{
@@ -198,6 +202,7 @@ export const appLanguages = [
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
},
},
{
@@ -213,6 +218,7 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
},
},
{
@@ -228,6 +234,7 @@ export const appLanguages = [
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
},
},
{
@@ -243,6 +250,7 @@ export const appLanguages = [
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
},
},
{
@@ -258,6 +266,7 @@ export const appLanguages = [
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
},
},
{
@@ -273,6 +282,23 @@ export const appLanguages = [
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
"de-DE": "Spanisch",
"pt-BR": "Espanhol",
"fr-FR": "Espagnol",
"zh-Hant-TW": "西班牙語",
"pt-PT": "Espanhol",
"ro-RO": "Spaniol",
"ja-JP": "スペイン語",
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
},
},
];
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
// Function to check if there are any users in the database
+1 -1
View File
@@ -1,6 +1,6 @@
import { IntegrationType, Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { IntegrationType, Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationInput } from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
+1 -1
View File
@@ -6,9 +6,9 @@ import {
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { getProject } from "@/lib/project/service";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
+1 -1
View File
@@ -1,7 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { OrganizationRole, Prisma, WidgetPlacement } from "@formbricks/database/generated/client";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import {
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
+2 -4
View File
@@ -1,8 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -480,12 +480,10 @@ export const getResponsesByEnvironmentId = reactCache(
}
);
// Use any for transaction client to avoid dist/src type mismatch in TypeScript
// Runtime behavior is correct, this is purely a type resolution issue
export const updateResponse = async (
responseId: string,
responseInput: TResponseUpdateInput,
tx?: any
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]);
try {
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { isAfter, isBefore, isSameDay } from "date-fns";
import { Prisma } from "@formbricks/database/generated/client";
import { TDisplay } from "@formbricks/types/displays";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
+1 -1
View File
@@ -12,9 +12,9 @@ import {
mockTags,
} from "./__mocks__/data.mock";
import { prisma } from "@/lib/__mocks__/database";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
+1 -1
View File
@@ -1,5 +1,5 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { Prisma } from "@formbricks/database/generated/client";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
+1 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@formbricks/database/generated/browser";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
+1 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@formbricks/database/generated/client";
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@formbricks/database/generated/client";
import { Prisma } from "@prisma/client";
import { TActionClass } from "@formbricks/types/action-classes";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";
+1 -1
View File
@@ -1,7 +1,7 @@
import { prisma } from "@/lib/__mocks__/database";
import { ActionClass, Prisma, Survey } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { ActionClass, Prisma, Survey } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TActionClass } from "@formbricks/types/action-classes";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ActionClass, Prisma } from "@formbricks/database/generated/client";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags";
import { TagError } from "@/modules/projects/settings/types/tag";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { Result, err, ok } from "@formbricks/types/error-handlers";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { DatabaseError } from "@formbricks/types/errors";
import { getResponse } from "../response/service";
import { addTagToRespone, deleteTagOnResponse, getTagsOnResponsesCount } from "./service";
+1 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/generated/client";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TTagsCount, TTagsOnResponses } from "@formbricks/types/tags";
+3 -1
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -103,6 +103,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return ja;
case "zh-Hans-CN":
return zhCN;
case "es-ES":
return es;
}
};

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