Merge branch 'main' into surveyBg

This commit is contained in:
Anjy Gupta
2023-10-30 14:33:40 +05:30
committed by GitHub
90 changed files with 4005 additions and 2510 deletions

View File

@@ -19,7 +19,7 @@ jobs:
SECRET=$(openssl rand -hex 32)
echo "NEXTAUTH_SECRET=$SECRET" >> $GITHUB_ENV
- name: Generate Random NEXTAUTH_SECRET
- name: Generate Random ENCRYPTION_KEY
run: |
SECRET=$(openssl rand -hex 32)
echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV
@@ -55,3 +55,4 @@ jobs:
build-args: |
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
DATABASE_URL=${{ env.DATABASE_URL }}
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}

View File

@@ -6,7 +6,7 @@
<h3 align="center">Formbricks</h3>
<p align="center">
The Open Source Survey & Experience Management solution for fast growing companies
The Open Source Survey & Experience Management solution for fast-growing companies
<br />
<a href="https://formbricks.com/">Website</a> | <a href="https://formbricks.com/discord">Join Discord community</a>
</p>
@@ -40,7 +40,7 @@
To celebrate Hacktoberfest, we've launched our FormTribe hackathon. Write code or perform non-code side quests to collect points and increase your chances of winning the MacBook Air M2!
**Join lottery with a [single tweet!](https://formtribe.com). All info on [formtribe.com](https://formtribe.com)**
**Join the lottery with a [single tweet!](https://formtribe.com). All info on [formtribe.com](https://formtribe.com)**
## ✨ About Formbricks
@@ -56,7 +56,7 @@ Formbricks helps you apply best practices from data-driven work and experience m
### Features
- 📲 Create **in-product surveys** with our no code editor with multiple question types.
- 📲 Create **in-product surveys** with our no-code editor with multiple question types.
- 📚 Choose from a variety of best-practice **templates**.
- 👩🏻 Launch and **target your surveys to specific user groups** without changing your application code.
- 🔗 Create shareable **link surveys**.
@@ -94,7 +94,7 @@ If you opt for self-hosting Formbricks, here are a few options to consider:
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community managed One Click Hosting
#### Community-managed One Click Hosting
##### Railway
@@ -132,7 +132,7 @@ Here are a few options:
- Star this repo.
- Create issues every time you feel something is missing or goes wrong.
- Upvote issues with 👍 reaction so we know what's the demand for a particular issue to prioritize it within the roadmap.
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@heroicons/react": "^2.0.18",
"next": "13.5.5",
"next": "14.0.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},

View File

@@ -35,7 +35,7 @@ export const meta = {
3. Installing Docker images by extracting them from the `packages/database/docker-compose.yml` file.
4. Building the @formbricks/js component.
- When the workspace starts:
1. Waiting for web and demo apps to start and openening the `apps/demo/.env` file automatically such that users can start playing around with the demo app by configuring `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID` straight away!
1. Wait for the web and demo apps to launch on Gitpod. This automatically opens the `apps/demo/.env` file. Utilize dynamic localhost URLs (e.g., `localhost:3000` for signup and `localhost:8025` for email confirmation) to configure `NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID`. After creating your account and finding the `ID` in the URL at `localhost:3000`, replace `YOUR_ENVIRONMENT_ID` in the `.env` file located in `app/demo`.
**Web Component Initialization:**
- we initialize the @formbricks/web component during prebuilds. This involves:

View File

@@ -81,7 +81,7 @@ You should store constants in `packages/lib/constants`
## Types should be in the packages folder
You should store type in `packages/types/v1`
You should store type in `packages/types`
## Read environment variables from `.env.mjs`

View File

@@ -116,7 +116,7 @@ export default function Header() {
}, []);
const stickyNavClass = stickyNav
? `bg-transparent shadow-md backdrop-blur-lg fixed top-0 z-30 w-full`
? `bg-transparent dark:bg-slate-900/[0.8] shadow-md backdrop-blur-lg fixed top-0 z-30 w-full`
: "relative";
return (
<Popover className={`${stickyNavClass}`} as="header">

View File

@@ -38,7 +38,7 @@
"lottie-web": "^5.12.2",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.3",
"next": "13.5.5",
"next": "13.4.19",
"next-plausible": "^3.11.1",
"next-seo": "^6.1.0",
"next-sitemap": "^4.2.3",

View File

@@ -52,6 +52,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
href: "https://formbricks.com",
},
{
name: "Firecamp",
description: "vscode for apis, open-source postman/insomnia alternative",
href: "https://firecamp.io",
},
{
name: "Ghostfolio",
description:
@@ -138,6 +143,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
description:
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
href: "https://www.sniffnet.net",
},
{
name: "Spark.NET",
description:
"The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
href: "https://spark-framework.net",
},
{
name: "Tolgee",
@@ -173,17 +184,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
description: "Webstudio is an open source alternative to Webflow",
href: "https://webstudio.is",
},
{
name: "Spark.NET",
description:
"The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
href: "https://spark-framework.net",
},
{
name: "Firecamp",
description: "vscode for apis, open-source postman/insomnia alternative",
href: "https://firecamp.io",
},
],
});
}

View File

@@ -50,21 +50,11 @@ const HowTo = [
];
const SideQuests = [
{
points: "Join the Tribe Tweet (100 Points)",
quest: "Tweet a single “🧱” emoji before the 7th of October EOD to join the #FormTribe.",
proof: "Share the link to the tweet in the “side-quest” channel.",
},
{
points: "Spread the Word Tweet (100 Points)",
quest: "Tweet “🧱🚀” on the day of the ProductHunt launch to spread the word.",
proof: "Share the link to the tweet in the “side-quest” channel.",
},
{
points: "Setup Insights (200 Points)",
quest: "Screen record yourself setting up the Formbricks dev environment.",
proof: "Upload to WeTransfer and send to johannes@formbricks.com",
},
{
points: "Meme Magic (50 Points + up to 100 Points)",
quest:
@@ -82,25 +72,15 @@ const SideQuests = [
quest: "Illustrate a captivating background for survey enthusiasts (more infos on Notion).",
proof: "Share the design in the “side-quest” channel.",
},
{
points: "Transform Animation to CSS (350 Points per background)",
quest: "Animate an existing background to CSS versions (more infos on Notion).",
proof: "Share the animated background.",
},
{
points: "Enhance Docs (50-250 Points)",
quest:
"Add a new section to our docs where you see gaps. Follow the current style of documentation incl. code snippets and screenshots. Pls no spam.",
proof: "Open a PR with “docs” in the title",
},
{
points: "Starry-eyed Supporter (250 Points)",
quest: "Get five friends to star our repository.",
proof: "Share 5 screenshots of the chats where you asked them and they confirmed + their GitHub names",
},
{
points: "Bug Hunter (50-250 Points)",
quest: "Find and report any functionality bugs.",
points: "Bug Hunter (100 Points)",
quest:
"Find and report any bugs in our core product. We will close all bugs on the landing page bc we don't have time for that before the launch :)",
proof: "Open a bug issue in our repository.",
},
{
@@ -109,11 +89,6 @@ const SideQuests = [
"Find someone whose name would be funny as a play on words with “brick”. Then, with the help of AI, create a brick version of this person like Brick Astley, Brickj Minaj, etc. For extra points, tweet it, tag us and score +5 for each like.",
proof: "Share your art or link to the tweet in the “side-quest” channel.",
},
{
points: "SEO Sage (50-250 Points)",
quest: "Provide detailed SEO recommendations or improvements for our main website.",
proof: "Share your insights.",
},
{
points: "Community Connector (50 points each, up to 250 points)",
quest:
@@ -359,7 +334,7 @@ const Leaderboard = [
},
{
name: "Aditya Deshlahre",
points: "1320",
points: "1370",
link: "https://github.com/adityadeshlahre",
},
{
@@ -392,7 +367,7 @@ const Leaderboard = [
},
{
name: "thanmaisai",
points: "860",
points: "1900",
},
{
name: "Rayyan Alam (Rayy)",
@@ -412,7 +387,7 @@ const Leaderboard = [
},
{
name: "Anjaneya Gupta",
points: "1650",
points: "2150",
},
{
name: "Sachin Kuber",
@@ -509,7 +484,7 @@ const Leaderboard = [
},
{
name: "Bilal Mirza",
points: "1025",
points: "1395",
},
{
name: "Asharan2511",
@@ -557,7 +532,7 @@ const Leaderboard = [
},
{
name: "Sachin Mittal",
points: "100",
points: "450",
},
{
name: "Sha1kh4",
@@ -637,7 +612,7 @@ const Leaderboard = [
},
{
name: "Shyam Raghu",
points: "300",
points: "400",
},
{
name: "Vikas Patil",
@@ -649,6 +624,22 @@ const Leaderboard = [
},
{
name: "mandharet",
points: "100",
},
{
name: "Harshit Vashisht",
points: "200",
},
{
name: "JiyaGupta-cs",
points: "50",
},
{
name: "Kurayami",
points: "100",
},
{
name: "Sandy-1711",
points: "50",
},
];

View File

@@ -7,6 +7,9 @@ ENV DATABASE_URL=$DATABASE_URL
ARG NEXTAUTH_SECRET
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ARG ENCRYPTION_KEY
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
WORKDIR /app
COPY . .

View File

@@ -1,8 +1,15 @@
"use client";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import { TNoCodeConfig } from "@formbricks/types/actionClasses";
import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClassInput, TActionClassNoCodeConfig, TNoCodeConfig } from "@formbricks/types/actionClasses";
import { Button } from "@formbricks/ui/Button";
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { TrashIcon } from "@heroicons/react/24/outline";
@@ -11,14 +18,6 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
interface ActionSettingsTabProps {
environmentId: string;
@@ -77,7 +76,8 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
try {
setIsUpdatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (!isPageUrl && !isCssSelector && !isInnerHtml) throw new Error("Please select atleast one selector");
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as TNoCodeConfig);
const updatedData: TActionClassInput = {

View File

@@ -1,19 +1,19 @@
"use client";
import { Modal } from "@formbricks/ui/Modal";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
import { TActionClassInput, TActionClassNoCodeConfig, TActionClass } from "@formbricks/types/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
interface AddNoCodeActionModalProps {
environmentId: string;
@@ -82,7 +82,8 @@ export default function AddNoCodeActionModal({
try {
setIsCreatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (!isPageUrl && !isCssSelector && !isInnerHtml) throw new Error("Please select atleast one selector");
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");

View File

@@ -10,7 +10,7 @@ export default async function MembersSettingsPage({ params }) {
throw new Error("Environment not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const environmentTagsCount = await getTagsOnResponsesCount();
const environmentTagsCount = await getTagsOnResponsesCount(params.environmentId);
return (
<div>

View File

@@ -0,0 +1,122 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { useMemo } from "react";
interface PictureChoiceSummaryProps {
questionSummary: TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>;
}
interface ChoiceResult {
id: string;
imageUrl: string;
count: number;
percentage?: number;
}
export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) {
const isMulti = questionSummary.question.allowMulti;
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.id] = {
id: choice.id,
imageUrl: choice.imageUrl,
count: 0,
percentage: 0,
};
}
// count the responses
for (const response of questionSummary.responses) {
if (Array.isArray(response.value)) {
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
}
}
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a, b) => {
return b.count - a.count;
});
return results;
}, [questionSummary]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} required={questionSummary.question.required} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{isMulti ? "Multi" : "Single"} Select
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result) => (
<div key={result.id}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal ">
<div className="relative h-32 w-[220px]">
<Image
src={result.imageUrl}
alt="choice-image"
layout="fill"
objectFit="cover"
className="rounded-md"
/>
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round((result.percentage || 0) * 100)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage || 0} />
</div>
))}
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[su
import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import {
@@ -22,6 +22,7 @@ import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
import OpenTextSummary from "./OpenTextSummary";
import RatingSummary from "./RatingSummary";
import PictureChoiceSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary";
interface SummaryListProps {
environment: TEnvironment;
@@ -125,6 +126,16 @@ export default function SummaryList({
/>
);
}
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
return (
<PictureChoiceSummary
key={questionSummary.question.id}
questionSummary={
questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>
}
/>
);
}
return null;
})}
{survey.hiddenFields?.enabled &&

View File

@@ -19,6 +19,7 @@ import {
Tailwind,
Text,
render,
Img,
} from "@react-email/components";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -410,6 +411,35 @@ const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
}
};

View File

@@ -100,10 +100,11 @@ export default function EditWelcomeCard({
</div>
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={environmentId}
onFileUpload={(url: string) => {
updateSurvey({ fileUrl: url });
onFileUpload={(url: string[]) => {
updateSurvey({ fileUrl: url[0] });
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
/>

View File

@@ -80,6 +80,7 @@ export default function LogicEditor({
],
cta: ["clicked", "skipped"],
consent: ["skipped", "accepted"],
pictureSelection: ["submitted", "skipped"],
};
const logicConditions: LogicConditions = {

View File

@@ -0,0 +1,114 @@
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
import FileInput from "@formbricks/ui/FileInput";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { createId } from "@paralleldrive/cuid2";
import { useState } from "react";
interface PictureSelectionFormProps {
localSurvey: TSurvey;
question: TSurveyPictureSelectionQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
isInValid: boolean;
}
export default function PictureSelectionForm({
localSurvey,
question,
questionIdx,
updateQuestion,
isInValid,
}: PictureSelectionFormProps): JSX.Element {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const environmentId = localSurvey.environmentId;
return (
<form>
<QuestionFormInput
environmentId={environmentId}
isInValid={isInValid}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
/>
<div className="mt-3">
{showSubheader && (
<>
<Label htmlFor="subheader">Description</Label>
<div className="mt-2 inline-flex w-full items-center">
<Input
id="subheader"
name="subheader"
value={question.subheader}
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
/>
<TrashIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
/>
</div>
</>
)}
{!showSubheader && (
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mt-2">
<Label htmlFor="Images">
Images{" "}
<span
className={cn("text-slate-400", {
"text-red-600": isInValid && question.choices?.length < 2,
})}>
(Upload at least 2 images)
</span>
</Label>
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
id="choices-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={environmentId}
onFileUpload={(urls: string[]) => {
updateQuestion(questionIdx, {
choices: urls.map((url) => ({ imageUrl: url, id: createId() })),
});
}}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
multiple={true}
/>
</div>
</div>
<div className="my-4 flex items-center space-x-2">
<Switch
id="multi-select-toggle"
checked={question.allowMulti}
onClick={(e) => {
e.stopPropagation();
updateQuestion(questionIdx, { allowMulti: !question.allowMulti });
}}
/>
<Label htmlFor="multi-select-toggle" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Allow Multi Select</h3>
<p className="text-xs font-normal text-slate-500">Allow users to select more than one image.</p>
</div>
</Label>
</div>
</form>
);
}

View File

@@ -18,6 +18,7 @@ import {
PresentationChartBarIcon,
QueueListIcon,
StarIcon,
PhotoIcon,
} from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
@@ -30,6 +31,7 @@ import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import PictureSelectionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm";
interface QuestionCardProps {
localSurvey: TSurvey;
@@ -133,8 +135,10 @@ export default function QuestionCard({
<CursorArrowRippleIcon />
) : question.type === TSurveyQuestionType.Rating ? (
<StarIcon />
) : question.type === "consent" ? (
) : question.type === TSurveyQuestionType.Consent ? (
<CheckIcon />
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PhotoIcon />
) : null}
</div>
<div>
@@ -215,7 +219,7 @@ export default function QuestionCard({
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : question.type === "consent" ? (
) : question.type === TSurveyQuestionType.Consent ? (
<ConsentQuestionForm
localSurvey={localSurvey}
question={question}
@@ -223,6 +227,15 @@ export default function QuestionCard({
updateQuestion={updateQuestion}
isInValid={isInValid}
/>
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PictureSelectionForm
localSurvey={localSurvey}
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
isInValid={isInValid}
/>
) : null}
<div className="mt-4">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">

View File

@@ -32,12 +32,13 @@ const QuestionFormInput = ({
<div className="mt-2 flex flex-col gap-6">
{showImageUploader && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={environmentId}
onFileUpload={(url: string) => {
updateQuestion(questionIdx, { imageUrl: url });
onFileUpload={(url: string[]) => {
updateQuestion(questionIdx, { imageUrl: url[0] });
}}
fileUrl={question.imageUrl || ""}
fileUrl={question.imageUrl}
/>
)}
<div className="flex items-center space-x-2">

View File

@@ -40,6 +40,11 @@ export default function UpdateQuestionId({
updateQuestion(questionIdx, { id: prevValue });
toast.error("ID should not be empty.");
return;
} else if (currentValue === "source" || currentValue === "suID" || currentValue === "userId") {
setCurrentValue(prevValue);
updateQuestion(questionIdx, { id: prevValue });
toast.error("ID cannot used reserved words.");
return;
} else {
setIsInputInvalid(false);
toast.success("Question ID updated.");

View File

@@ -4,6 +4,7 @@ import {
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
} from "@formbricks/types/surveys";
@@ -17,6 +18,9 @@ const validationRules = {
consent: (question: TSurveyConsentQuestion) => {
return question.label.trim() !== "";
},
pictureSelection: (question: TSurveyPictureSelectionQuestion) => {
return question.choices.length >= 2;
},
defaultValidation: (question: TSurveyQuestion) => {
return question.headline.trim() !== "";
},

View File

@@ -144,7 +144,7 @@ export default function PreviewSurvey({
useEffect(() => {
// close modal if there are no questions left
if (survey.type === "web" && !survey.thankYouCard.enabled) {
if (activeQuestionId === "thank-you-card") {
if (activeQuestionId === "end") {
setIsModalOpen(false);
setTimeout(() => {
setActiveQuestionId(survey.questions[0].id);

View File

@@ -1,7 +1,7 @@
import { getUpdatedState } from "@/app/api/v1/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createAttributeClass, getAttributeClassByNameCached } from "@formbricks/lib/attributeClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
@@ -35,7 +35,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByNameCached(environmentId, key);
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {

View File

@@ -2,8 +2,10 @@ import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { displayCache } from "@formbricks/lib/display/cache";
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
import { getProductByEnvironmentIdCached, getProductCacheTag } from "@formbricks/lib/product/service";
import { getSurveyCacheTag, getSurveys } from "@formbricks/lib/survey/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { productCache } from "@formbricks/lib/product/cache";
import { getSurveys } from "@formbricks/lib/survey/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { unstable_cache } from "next/cache";
@@ -23,8 +25,8 @@ export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
{
tags: [
displayCache.tag.byPersonId(person.id),
getSurveyCacheTag(environmentId),
getProductCacheTag(environmentId),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
@@ -35,7 +37,7 @@ export const getSyncSurveys = async (
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentIdCached(environmentId);
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");

View File

@@ -3,8 +3,8 @@ import { MAU_LIMIT } from "@formbricks/lib/constants";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { createPerson, getMonthlyActivePeopleCount, getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentIdCached } from "@formbricks/lib/product/service";
import { createSession, extendSession, getSessionCached } from "@formbricks/lib/session/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { createSession, extendSession, getSession } from "@formbricks/lib/session/service";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsState } from "@formbricks/types/js";
@@ -41,7 +41,7 @@ export const getUpdatedState = async (
// don't allow new people or sessions
throw new Error(errorMessage);
}
const session = await getSessionCached(sessionId);
const session = await getSession(sessionId);
if (!session) {
// don't allow new sessions
throw new Error(errorMessage);
@@ -74,7 +74,7 @@ export const getUpdatedState = async (
session = await createSession(person.id);
} else {
// check validity of person & session
session = await getSessionCached(sessionId);
session = await getSession(sessionId);
if (!session) {
// create a new session
session = await createSession(person.id);
@@ -102,7 +102,7 @@ export const getUpdatedState = async (
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveysCached(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentIdCached(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {

View File

@@ -1,4 +1,5 @@
import { ImageResponse, NextRequest } from "next/server";
import { NextRequest } from "next/server";
import { ImageResponse } from "next/og";
// App router includes @vercel/og.
// No need to install it.

View File

@@ -36,7 +36,7 @@ input:focus {
}
@layer utilities {
@variants responsive {
@layer responsive {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
@@ -61,4 +61,4 @@ input[type="search"]::-ms-clear {
input[type="search"]::-ms-reveal {
display: none;
}
}

View File

@@ -1,15 +1,16 @@
import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys";
import {
CursorArrowRippleIcon,
ChatBubbleBottomCenterTextIcon,
CheckIcon,
CursorArrowRippleIcon,
ListBulletIcon,
PhotoIcon,
PresentationChartBarIcon,
QueueListIcon,
StarIcon,
CheckIcon,
} from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { replaceQuestionPresetPlaceholders } from "./templates";
import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys";
export type TSurveyQuestionType = {
id: string;
@@ -62,9 +63,44 @@ export const questionTypes: TSurveyQuestionType[] = [
shuffleOption: "none",
},
},
{
id: QuestionId.PictureSelection,
label: "Picture Selection",
description: "Select one or more pictures",
icon: PhotoIcon,
preset: {
headline: "Which is the cutest puppy?",
subheader: "You can also pick both.",
allowMulti: true,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
},
{
id: QuestionId.Rating,
label: "Rating",
description: "Ask your users to rate something",
icon: StarIcon,
preset: {
headline: "How would you rate {{productName}}",
subheader: "Don't worry, be honest.",
scale: "star",
range: 5,
lowerLabel: "Not good",
upperLabel: "Very good",
},
},
{
id: QuestionId.NPS,
label: "Net Promoter Score® (NPS)",
label: "Net Promoter Score (NPS)",
description: "Rate satisfaction on a 0-10 scale",
icon: PresentationChartBarIcon,
preset: {
@@ -86,21 +122,7 @@ export const questionTypes: TSurveyQuestionType[] = [
},
},
{
id: QuestionId.Rating,
label: "Rating",
description: "Ask your users to rate something",
icon: StarIcon,
preset: {
headline: "How would you rate {{productName}}",
subheader: "Don't worry, be honest.",
scale: "star",
range: 5,
lowerLabel: "Not good",
upperLabel: "Very good",
},
},
{
id: "consent",
id: QuestionId.Consent,
label: "Consent",
description: "Ask your users to accept something",
icon: CheckIcon,

View File

@@ -84,6 +84,12 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean =
if (answerNumber < 1 || answerNumber > question.range) return false;
return true;
}
case TSurveyQuestionType.PictureSelection: {
answer = answer.split(",");
if (!answer.every((ans: string) => question.choices.find((choice) => choice.id === ans)))
return false;
return true;
}
default:
return false;
}
@@ -107,6 +113,10 @@ export const transformAnswer = (question: TSurveyQuestion, answer: string): stri
return Number(JSON.parse(answer));
}
case TSurveyQuestionType.PictureSelection: {
return answer.split(",");
}
case TSurveyQuestionType.MultipleChoiceMulti: {
let ansArr = answer.split(",");
const hasOthers = question.choices[question.choices.length - 1].id === "other";

View File

@@ -1,15 +1,12 @@
import { createId } from "@paralleldrive/cuid2";
import { withSentryConfig } from "@sentry/nextjs";
import "./env.mjs";
import { createId } from "@paralleldrive/cuid2";
/** @type {import('next').NextConfig} */
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
output: "standalone",
experimental: {
serverActions: true,
},
transpilePackages: ["@formbricks/database", "@formbricks/ee", "@formbricks/ui", "@formbricks/lib"],
images: {
remotePatterns: [
@@ -29,6 +26,10 @@ const nextConfig = {
protocol: "https",
hostname: "app.formbricks.com",
},
{
protocol: "https",
hostname: "formbricks-cdn.s3.eu-central-1.amazonaws.com",
},
],
},
async redirects() {

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -39,7 +39,7 @@
"lru-cache": "^10.0.1",
"lucide-react": "^0.288.0",
"mime": "^3.0.0",
"next": "13.5.6",
"next": "14.0.0",
"nodemailer": "^6.9.7",
"otplib": "^12.0.1",
"posthog-js": "^1.85.1",

View File

@@ -12,18 +12,8 @@ export class ResponseAPI {
this.apiHost = apiHost;
}
async create({
surveyId,
personId,
finished,
data,
}: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, "/api/v1/client/responses", "POST", {
surveyId,
personId,
finished,
data,
});
async create(responseInput: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, "/api/v1/client/responses", "POST", responseInput);
}
async update({

View File

@@ -10,7 +10,7 @@ import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { actionClassCache } from "../actionClass/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getSessionCached } from "../session/service";
import { getSession } from "../session/service";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
@@ -136,7 +136,7 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
eventType = "automatic";
}
const session = await getSessionCached(sessionId);
const session = await getSession(sessionId);
if (!session) {
throw new ResourceNotFoundError("Session", sessionId);

View File

@@ -6,6 +6,7 @@ import { hasUserEnvironmentAccess } from "../environment/auth";
import { getApiKey } from "./service";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { apiKeyCache } from "./cache";
export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Promise<boolean> =>
await unstable_cache(
@@ -21,6 +22,6 @@ export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Pro
return true;
},
[`users-${userId}-apiKeys-${apiKeyId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [`apiKeys-${apiKeyId}`] }
[`canUserAccessApiKey-${userId}-${apiKeyId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [apiKeyCache.tag.byId(apiKeyId)] }
)();

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
hashedKey?: string;
}
export const apiKeyCache = {
tag: {
byId(id: string) {
return `apiKeys-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-apiKeys`;
},
byHashedKey(hashedKey: string) {
return `apiKeys-${hashedKey}-apiKey`;
},
},
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (hashedKey) {
revalidateTag(this.tag.byHashedKey(hashedKey));
}
},
};

View File

@@ -9,55 +9,74 @@ import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbr
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { ITEMS_PER_PAGE } from "../constants";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { apiKeyCache } from "./cache";
export const getApiKey = async (apiKeyId: string): Promise<TApiKey | null> => {
validateInputs([apiKeyId, ZString]);
if (!apiKeyId) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
export const getApiKey = async (apiKeyId: string): Promise<TApiKey | null> =>
unstable_cache(
async () => {
validateInputs([apiKeyId, ZString]);
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
id: apiKeyId,
},
});
if (!apiKeyId) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
if (!apiKeyData) {
throw new ResourceNotFoundError("API Key from ID", apiKeyId);
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
id: apiKeyId,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("API Key from ID", apiKeyId);
}
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKey-${apiKeyId}`],
{
tags: [apiKeyCache.tag.byId(apiKeyId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
export const getApiKeys = async (environmentId: string, page?: number): Promise<TApiKey[]> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const apiKeys = await prisma.apiKey.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return apiKeys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeys-${environmentId}-${page}`],
{
tags: [apiKeyCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
};
export const getApiKeys = async (environmentId: string, page?: number): Promise<TApiKey[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const apiKeys = await prisma.apiKey.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return apiKeys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
)();
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
@@ -75,6 +94,12 @@ export async function createApiKey(environmentId: string, apiKeyData: TApiKeyCre
},
});
apiKeyCache.revalidate({
id: result.id,
hashedKey: result.hashedKey,
environmentId: result.environmentId,
});
return { ...result, apiKey: key };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -85,30 +110,43 @@ export async function createApiKey(environmentId: string, apiKeyData: TApiKeyCre
}
export const getApiKeyFromKey = async (apiKey: string): Promise<TApiKey | null> => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
const hashedKey = getHash(apiKey);
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: getHash(apiKey),
},
});
return unstable_cache(
async () => {
validateInputs([apiKey, ZString]);
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
});
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeyFromKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
)();
};
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
validateInputs([id, ZId]);
try {
const deletedApiKeyData = await prisma.apiKey.delete({
where: {
@@ -116,6 +154,12 @@ export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
},
});
apiKeyCache.revalidate({
id: deletedApiKeyData.id,
hashedKey: deletedApiKeyData.hashedKey,
environmentId: deletedApiKeyData.environmentId,
});
return deletedApiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
name?: string;
environmentId?: string;
}
export const attributeClassCache = {
tag: {
byId(id: string) {
return `attributeClass-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-attributeClasses`;
},
byEnvironmentIdAndName(environmentId: string, name: string) {
return `environments-${environmentId}-name-${name}-attributeClasses`;
},
},
revalidate({ id, environmentId, name }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (environmentId && name) {
revalidateTag(this.tag.byEnvironmentIdAndName(environmentId, name));
}
},
};

View File

@@ -7,57 +7,81 @@ import {
TAttributeClassUpdateInput,
ZAttributeClassUpdateInput,
TAttributeClassType,
ZAttributeClassType,
} from "@formbricks/types/attributeClasses";
import { ZId } from "@formbricks/types/environment";
import { validateInputs } from "../utils/validate";
import { DatabaseError } from "@formbricks/types/errors";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants";
import { ZOptionalNumber } from "@formbricks/types/common";
const attributeClassesCacheTag = (environmentId: string): string =>
`environments-${environmentId}-attributeClasses`;
const getAttributeClassesCacheKey = (environmentId: string): string[] => [
attributeClassesCacheTag(environmentId),
];
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { attributeClassCache } from "./cache";
import { formatAttributeClassDateFields } from "./util";
export const getAttributeClass = async (attributeClassId: string): Promise<TAttributeClass | null> => {
validateInputs([attributeClassId, ZId]);
try {
const attributeClass = await prisma.attributeClass.findFirst({
where: {
id: attributeClassId,
},
});
return attributeClass;
} catch (error) {
throw new DatabaseError(`Database error when fetching attributeClass with id ${attributeClassId}`);
const attributeClass = await unstable_cache(
async () => {
validateInputs([attributeClassId, ZId]);
try {
return await prisma.attributeClass.findFirst({
where: {
id: attributeClassId,
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching attributeClass with id ${attributeClassId}`);
}
},
[`getAttributeClass-${attributeClassId}`],
{
tags: [attributeClassCache.tag.byId(attributeClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!attributeClass) {
return null;
}
return formatAttributeClassDateFields(attributeClass);
};
export const getAttributeClasses = async (
environmentId: string,
page?: number
): Promise<TAttributeClass[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
const attributeClasses = await unstable_cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const attributeClasses = await prisma.attributeClass.findMany({
where: {
environmentId: environmentId,
},
orderBy: {
createdAt: "asc",
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
try {
const attributeClasses = await prisma.attributeClass.findMany({
where: {
environmentId: environmentId,
},
orderBy: {
createdAt: "asc",
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return attributeClasses;
} catch (error) {
throw new DatabaseError(`Database error when fetching attributeClasses for environment ${environmentId}`);
}
return attributeClasses;
} catch (error) {
throw new DatabaseError(
`Database error when fetching attributeClasses for environment ${environmentId}`
);
}
},
[`getAttributeClasses-${environmentId}-${page}`],
{
tags: [attributeClassCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return attributeClasses.map(formatAttributeClassDateFields);
};
export const updatetAttributeClass = async (
@@ -65,6 +89,7 @@ export const updatetAttributeClass = async (
data: Partial<TAttributeClassUpdateInput>
): Promise<TAttributeClass | null> => {
validateInputs([attributeClassId, ZId], [data, ZAttributeClassUpdateInput.partial()]);
try {
const attributeClass = await prisma.attributeClass.update({
where: {
@@ -76,7 +101,11 @@ export const updatetAttributeClass = async (
},
});
revalidateTag(attributeClassesCacheTag(attributeClass.environmentId));
attributeClassCache.revalidate({
id: attributeClass.id,
environmentId: attributeClass.environmentId,
name: attributeClass.name,
});
return attributeClass;
} catch (error) {
@@ -84,36 +113,32 @@ export const updatetAttributeClass = async (
}
};
export const getAttributeClassByNameCached = async (environmentId: string, name: string) =>
export const getAttributeClassByName = async (environmentId: string, name: string) =>
await unstable_cache(
async (): Promise<TAttributeClass | null> => {
return await getAttributeClassByName(environmentId, name);
validateInputs([environmentId, ZId], [name, ZString]);
return await prisma.attributeClass.findFirst({
where: {
environmentId,
name,
},
});
},
[`environments-${environmentId}-attributeClass-${name}`],
[`getAttributeClassByName-${environmentId}-${name}`],
{
tags: getAttributeClassesCacheKey(environmentId),
tags: [attributeClassCache.tag.byEnvironmentIdAndName(environmentId, name)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getAttributeClassByName = async (
environmentId: string,
name: string
): Promise<TAttributeClass | null> => {
const attributeClass = await prisma.attributeClass.findFirst({
where: {
environmentId,
name,
},
});
return attributeClass;
};
export const createAttributeClass = async (
environmentId: string,
name: string,
type: TAttributeClassType
): Promise<TAttributeClass | null> => {
validateInputs([environmentId, ZId], [name, ZString], [type, ZAttributeClassType]);
const attributeClass = await prisma.attributeClass.create({
data: {
name,
@@ -125,12 +150,19 @@ export const createAttributeClass = async (
},
},
});
revalidateTag(attributeClassesCacheTag(environmentId));
attributeClassCache.revalidate({
id: attributeClass.id,
environmentId: attributeClass.environmentId,
name: attributeClass.name,
});
return attributeClass;
};
export const deleteAttributeClass = async (attributeClassId: string): Promise<TAttributeClass> => {
validateInputs([attributeClassId, ZId]);
try {
const deletedAttributeClass = await prisma.attributeClass.delete({
where: {
@@ -138,6 +170,12 @@ export const deleteAttributeClass = async (attributeClassId: string): Promise<TA
},
});
attributeClassCache.revalidate({
id: deletedAttributeClass.id,
environmentId: deletedAttributeClass.environmentId,
name: deletedAttributeClass.name,
});
return deletedAttributeClass;
} catch (error) {
throw new DatabaseError(`Database error when deleting webhook with ID ${attributeClassId}`);

View File

@@ -0,0 +1,14 @@
import "server-only";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
export const formatAttributeClassDateFields = (attributeClass: TAttributeClass): TAttributeClass => {
if (typeof attributeClass.createdAt === "string") {
attributeClass.createdAt = new Date(attributeClass.createdAt);
}
if (typeof attributeClass.updatedAt === "string") {
attributeClass.updatedAt = new Date(attributeClass.updatedAt);
}
return attributeClass;
};

View File

@@ -5,8 +5,7 @@ import { prisma } from "@formbricks/database";
import { symmetricDecrypt, symmetricEncrypt } from "../crypto";
import { verifyPassword } from "../auth";
import { totpAuthenticatorCheck } from "../totp";
import { revalidateTag } from "next/cache";
import { getProfileCacheTag } from "../profile/service";
import { profileCache } from "../profile/cache";
import { ENCRYPTION_KEY } from "../constants";
export const setupTwoFactorAuth = async (
@@ -71,10 +70,10 @@ export const setupTwoFactorAuth = async (
return { secret, keyUri, dataUri, backupCodes };
};
export const enableTwoFactorAuth = async (userId: string, code: string) => {
export const enableTwoFactorAuth = async (id: string, code: string) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
id,
},
});
@@ -114,14 +113,16 @@ export const enableTwoFactorAuth = async (userId: string, code: string) => {
await prisma.user.update({
where: {
id: userId,
id,
},
data: {
twoFactorEnabled: true,
},
});
revalidateTag(getProfileCacheTag(userId));
profileCache.revalidate({
id,
});
return {
message: "Two factor authentication enabled",
@@ -134,10 +135,10 @@ type TDisableTwoFactorAuthParams = {
backupCode?: string;
};
export const disableTwoFactorAuth = async (userId: string, params: TDisableTwoFactorAuthParams) => {
export const disableTwoFactorAuth = async (id: string, params: TDisableTwoFactorAuthParams) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
id,
},
});
@@ -211,7 +212,7 @@ export const disableTwoFactorAuth = async (userId: string, params: TDisableTwoFa
await prisma.user.update({
where: {
id: userId,
id,
},
data: {
backupCodes: null,
@@ -220,7 +221,9 @@ export const disableTwoFactorAuth = async (userId: string, params: TDisableTwoFa
},
});
revalidateTag(getProfileCacheTag(userId));
profileCache.revalidate({
id,
});
return {
message: "Two factor authentication disabled",

View File

@@ -3,13 +3,13 @@ import { ZId } from "@formbricks/types/environment";
import { unstable_cache } from "next/cache";
import { validateInputs } from "../utils/validate";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getTeamsByUserIdCacheTag } from "../team/service";
import { revalidateTag } from "next/cache";
import { teamCache } from "../team/cache";
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => {
return await unstable_cache(
async (): Promise<boolean> => {
validateInputs([userId, ZId], [environmentId, ZId]);
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
@@ -30,12 +30,14 @@ export const hasUserEnvironmentAccess = async (userId: string, environmentId: st
},
},
});
revalidateTag(getTeamsByUserIdCacheTag(userId));
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
return environmentUsers.includes(userId);
},
[`users-${userId}-environments-${environmentId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [`environments-${environmentId}`] }
[`hasUserEnvironmentAccess-${userId}-${environmentId}`],
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [teamCache.tag.byEnvironmentId(environmentId), teamCache.tag.byUserId(userId)],
}
)();
};

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
productId?: string;
userId?: string;
}
export const environmentCache = {
tag: {
byId(id: string) {
return `environments-${id}`;
},
byProductId(productId: string) {
return `products-${productId}-environments`;
},
byUserId(userId: string) {
return `users-${userId}-environments`;
},
},
revalidate({ id, productId, userId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (productId) {
revalidateTag(this.tag.byProductId(productId));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
},
};

View File

@@ -14,14 +14,13 @@ import {
} from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import "server-only";
import { z } from "zod";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
export const getEnvironmentCacheTag = (environmentId: string) => `environments-${environmentId}`;
export const getEnvironmentsCacheTag = (productId: string) => `products-${productId}-environments`;
import { environmentCache } from "./cache";
import { formatEnvironmentDateFields } from "./util";
export const getEnvironment = (environmentId: string) =>
unstable_cache(
@@ -54,9 +53,9 @@ export const getEnvironment = (environmentId: string) =>
throw new ValidationError("Data validation of environment failed");
}
},
[`environments-${environmentId}`],
[`getEnvironment-${environmentId}`],
{
tags: [getEnvironmentCacheTag(environmentId)],
tags: [environmentCache.tag.byId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -101,9 +100,9 @@ export const getEnvironments = async (productId: string): Promise<TEnvironment[]
throw new ValidationError("Data validation of environments array failed");
}
},
[`products-${productId}-environments`],
[`getEnvironments-${productId}`],
{
tags: [getEnvironmentsCacheTag(productId)],
tags: [environmentCache.tag.byProductId(productId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -123,8 +122,10 @@ export const updateEnvironment = async (
data: newData,
});
revalidateTag(getEnvironmentsCacheTag(updatedEnvironment.productId));
revalidateTag(getEnvironmentCacheTag(environmentId));
environmentCache.revalidate({
id: environmentId,
productId: updatedEnvironment.productId,
});
return updatedEnvironment;
} catch (error) {
@@ -136,29 +137,40 @@ export const updateEnvironment = async (
};
export const getFirstEnvironmentByUserId = async (userId: string): Promise<TEnvironment | null> => {
validateInputs([userId, ZId]);
try {
return await prisma.environment.findFirst({
where: {
type: "production",
product: {
team: {
memberships: {
some: {
userId,
const environment = await unstable_cache(
async () => {
validateInputs([userId, ZId]);
try {
return await prisma.environment.findFirst({
where: {
type: "production",
product: {
team: {
memberships: {
some: {
userId,
},
},
},
},
},
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
throw error;
}
},
[`getFirstEnvironmentByUserId-${userId}`],
{
tags: [environmentCache.tag.byUserId(userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return environment ? formatEnvironmentDateFields(environment) : environment;
};
export const createEnvironment = async (
@@ -167,7 +179,7 @@ export const createEnvironment = async (
): Promise<TEnvironment> => {
validateInputs([productId, ZId], [environmentInput, ZEnvironmentCreateInput]);
return await prisma.environment.create({
const environment = await prisma.environment.create({
data: {
type: environmentInput.type || "development",
product: { connect: { id: productId } },
@@ -199,4 +211,11 @@ export const createEnvironment = async (
},
},
});
environmentCache.revalidate({
id: environment.id,
productId: environment.productId,
});
return environment;
};

View File

@@ -0,0 +1,14 @@
import "server-only";
import { TEnvironment } from "@formbricks/types/environment";
export const formatEnvironmentDateFields = (environemt: TEnvironment): TEnvironment => {
if (typeof environemt.createdAt === "string") {
environemt.createdAt = new Date(environemt.createdAt);
}
if (typeof environemt.updatedAt === "string") {
environemt.updatedAt = new Date(environemt.updatedAt);
}
return environemt;
};

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
type?: string;
}
export const integrationCache = {
tag: {
byId(id: string) {
return `integrations-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-integrations`;
},
byEnvironmentIdAndType(environmentId: string, type: string) {
return `environments-${environmentId}-type-${type}-integrations`;
},
},
revalidate({ id, environmentId, type }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (environmentId && type) {
revalidateTag(this.tag.byEnvironmentIdAndType(environmentId, type));
}
},
};

View File

@@ -7,7 +7,9 @@ import { ZId } from "@formbricks/types/environment";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import { validateInputs } from "../utils/validate";
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { ITEMS_PER_PAGE } from "../constants";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { integrationCache } from "./cache";
import { unstable_cache } from "next/cache";
export async function createOrUpdateIntegration(
environmentId: string,
@@ -32,6 +34,10 @@ export async function createOrUpdateIntegration(
environment: { connect: { id: environmentId } },
},
});
integrationCache.revalidate({
environmentId,
});
return integration;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -42,66 +48,87 @@ export async function createOrUpdateIntegration(
}
}
export const getIntegrations = async (environmentId: string, page?: number): Promise<TIntegration[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
export const getIntegrations = async (environmentId: string, page?: number): Promise<TIntegration[]> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const result = await prisma.integration.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
try {
const result = await prisma.integration.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getIntegrations-${environmentId}-${page}`],
{
tags: [integrationCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
};
)();
export const getIntegration = async (integrationId: string): Promise<TIntegration | null> => {
try {
const result = await prisma.integration.findUnique({
where: {
id: integrationId,
},
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getIntegration = async (integrationId: string): Promise<TIntegration | null> =>
unstable_cache(
async () => {
try {
const result = await prisma.integration.findUnique({
where: {
id: integrationId,
},
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getIntegration-${integrationId}`],
{ tags: [integrationCache.tag.byId(integrationId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
export const getIntegrationByType = async (
environmentId: string,
type: TIntegrationInput["type"]
): Promise<TIntegration | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
): Promise<TIntegration | null> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
const result = await prisma.integration.findUnique({
where: {
type_environmentId: {
environmentId,
type,
},
},
});
try {
const result = await prisma.integration.findUnique({
where: {
type_environmentId: {
environmentId,
type,
},
},
});
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getIntegrationByType-${environmentId}-${type}`],
{
tags: [integrationCache.tag.byEnvironmentIdAndType(environmentId, type)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
};
)();
export const deleteIntegration = async (integrationId: string): Promise<TIntegration> => {
validateInputs([integrationId, ZString]);
@@ -113,6 +140,12 @@ export const deleteIntegration = async (integrationId: string): Promise<TIntegra
},
});
integrationCache.revalidate({
id: integrationData.id,
environmentId: integrationData.environmentId,
type: integrationData.type,
});
return integrationData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -0,0 +1,26 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
teamId?: string;
}
export const inviteCache = {
tag: {
byId(id: string) {
return `invites-${id}`;
},
byTeamId(teamId: string) {
return `teams-${teamId}-invites`;
},
},
revalidate({ id, teamId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (teamId) {
revalidateTag(this.tag.byTeamId(teamId));
}
},
};

View File

@@ -15,7 +15,11 @@ import { ResourceNotFoundError, ValidationError, DatabaseError } from "@formbric
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { sendInviteMemberEmail } from "../emails/emails";
import { validateInputs } from "../utils/validate";
import { ITEMS_PER_PAGE } from "../constants";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { inviteCache } from "./cache";
import { formatInviteDateFields } from "./util";
import { getMembershipByUserIdTeamId } from "../membership/service";
const inviteSelect = {
id: true,
@@ -31,16 +35,25 @@ const inviteSelect = {
};
export const getInvitesByTeamId = async (teamId: string, page?: number): Promise<TInvite[] | null> => {
validateInputs([teamId, ZString], [page, ZOptionalNumber]);
const invites = await unstable_cache(
async () => {
validateInputs([teamId, ZString], [page, ZOptionalNumber]);
const invites = await prisma.invite.findMany({
where: { teamId },
select: inviteSelect,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return prisma.invite.findMany({
where: { teamId },
select: inviteSelect,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
},
[`getInvitesByTeamId-${teamId}-${page}`],
{
tags: [inviteCache.tag.byTeamId(teamId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return invites;
return invites.map(formatInviteDateFields);
};
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<TInvite | null> => {
@@ -53,6 +66,15 @@ export const updateInvite = async (inviteId: string, data: TInviteUpdateInput):
select: inviteSelect,
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
teamId: invite.teamId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
@@ -77,6 +99,11 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
teamId: invite.teamId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -87,27 +114,32 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvite = async (inviteId: string): Promise<{ inviteId: string; email: string }> => {
validateInputs([inviteId, ZString]);
export const getInvite = async (inviteId: string): Promise<{ inviteId: string; email: string }> =>
unstable_cache(
async () => {
validateInputs([inviteId, ZString]);
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
return {
inviteId,
email: invite.email,
};
},
select: {
email: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
return {
inviteId,
email: invite.email,
};
};
[`getInvite-${inviteId}`],
{ tags: [inviteCache.tag.byId(inviteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
export const resendInvite = async (inviteId: string): Promise<TInvite> => {
validateInputs([inviteId, ZString]);
@@ -137,6 +169,11 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
},
});
inviteCache.revalidate({
id: updatedInvite.id,
teamId: updatedInvite.teamId,
});
return updatedInvite;
};
@@ -162,11 +199,8 @@ export const inviteUser = async ({
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
const member = await prisma.membership.findUnique({
where: {
userId_teamId: { teamId, userId: user.id },
},
});
const member = await getMembershipByUserIdTeamId(user.id, teamId);
if (member) {
throw new ValidationError("User is already a member of this team");
}
@@ -189,5 +223,10 @@ export const inviteUser = async ({
await sendInviteMemberEmail(invite.id, email, currentUserName, name);
inviteCache.revalidate({
id: invite.id,
teamId: invite.teamId,
});
return invite;
};

View File

@@ -0,0 +1,14 @@
import "server-only";
import { TInvite } from "@formbricks/types/invites";
export const formatInviteDateFields = (invite: TInvite): TInvite => {
if (typeof invite.createdAt === "string") {
invite.createdAt = new Date(invite.createdAt);
}
if (typeof invite.expiresAt === "string") {
invite.expiresAt = new Date(invite.expiresAt);
}
return invite;
};

View File

@@ -0,0 +1,26 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
userId?: string;
teamId?: string;
}
export const membershipCache = {
tag: {
byTeamId(teamId: string) {
return `teams-${teamId}-memberships`;
},
byUserId(userId: string) {
return `users-${userId}-memberships`;
},
},
revalidate({ teamId, userId }: RevalidateProps): void {
if (teamId) {
revalidateTag(this.tag.byTeamId(teamId));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
},
};

View File

@@ -12,76 +12,101 @@ import {
import { Prisma } from "@prisma/client";
import { validateInputs } from "../utils/validate";
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { getTeamsByUserIdCacheTag } from "../team/service";
import { revalidateTag } from "next/cache";
import { ITEMS_PER_PAGE } from "../constants";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { membershipCache } from "./cache";
import { teamCache } from "../team/cache";
export const getMembersByTeamId = async (teamId: string, page?: number): Promise<TMember[]> => {
validateInputs([teamId, ZString], [page, ZOptionalNumber]);
export const getMembersByTeamId = async (teamId: string, page?: number): Promise<TMember[]> =>
unstable_cache(
async () => {
validateInputs([teamId, ZString], [page, ZOptionalNumber]);
const membersData = await prisma.membership.findMany({
where: { teamId },
select: {
user: {
const membersData = await prisma.membership.findMany({
where: { teamId },
select: {
name: true,
email: true,
user: {
select: {
name: true,
email: true,
},
},
userId: true,
accepted: true,
role: true,
},
},
userId: true,
accepted: true,
role: true,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const members = membersData.map((member) => {
return {
name: member.user?.name || "",
email: member.user?.email || "",
userId: member.userId,
accepted: member.accepted,
role: member.role,
};
});
return members;
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const members = membersData.map((member) => {
return {
name: member.user?.name || "",
email: member.user?.email || "",
userId: member.userId,
accepted: member.accepted,
role: member.role,
};
});
return members;
};
[`getMembersByTeamId-${teamId}-${page}`],
{
tags: [membershipCache.tag.byTeamId(teamId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getMembershipByUserIdTeamId = async (
userId: string,
teamId: string
): Promise<TMembership | null> => {
validateInputs([userId, ZString], [teamId, ZString]);
): Promise<TMembership | null> =>
unstable_cache(
async () => {
validateInputs([userId, ZString], [teamId, ZString]);
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!membership) return null;
return membership;
},
});
[`getMembershipByUserIdTeamId-${userId}-${teamId}`],
{
tags: [membershipCache.tag.byUserId(userId), membershipCache.tag.byTeamId(teamId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!membership) return null;
export const getMembershipsByUserId = async (userId: string, page?: number): Promise<TMembership[]> =>
unstable_cache(
async () => {
validateInputs([userId, ZString], [page, ZOptionalNumber]);
return membership;
};
const memberships = await prisma.membership.findMany({
where: {
userId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
export const getMembershipsByUserId = async (userId: string, page?: number): Promise<TMembership[]> => {
validateInputs([userId, ZString], [page, ZOptionalNumber]);
const memberships = await prisma.membership.findMany({
where: {
userId,
return memberships;
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return memberships;
};
[`getMembershipsByUserId-${userId}-${page}`],
{
tags: [membershipCache.tag.byUserId(userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const createMembership = async (
teamId: string,
@@ -89,6 +114,7 @@ export const createMembership = async (
data: Partial<TMembership>
): Promise<TMembership> => {
validateInputs([teamId, ZString], [userId, ZString], [data, ZMembership.partial()]);
try {
const membership = await prisma.membership.create({
data: {
@@ -98,13 +124,21 @@ export const createMembership = async (
role: data.role as TMembership["role"],
},
});
revalidateTag(getTeamsByUserIdCacheTag(userId));
teamCache.revalidate({
userId,
});
membershipCache.revalidate({
userId,
teamId,
});
return membership;
} catch (error) {
throw error;
}
};
export const updateMembership = async (
userId: string,
teamId: string,
@@ -122,7 +156,15 @@ export const updateMembership = async (
},
data,
});
revalidateTag(getTeamsByUserIdCacheTag(userId));
teamCache.revalidate({
userId,
});
membershipCache.revalidate({
userId,
teamId,
});
return membership;
} catch (error) {
@@ -145,7 +187,15 @@ export const deleteMembership = async (userId: string, teamId: string): Promise<
},
},
});
revalidateTag(getTeamsByUserIdCacheTag(userId));
teamCache.revalidate({
userId,
});
membershipCache.revalidate({
userId,
teamId,
});
return deletedMembership;
};
@@ -182,7 +232,17 @@ export const transferOwnership = async (
},
}),
]);
revalidateTag(getTeamsByUserIdCacheTag(teamId));
memberships.forEach((membership) => {
teamCache.revalidate({
userId: membership.userId,
});
membershipCache.revalidate({
userId: membership.userId,
teamId: membership.teamId,
});
});
return memberships;
} catch (error) {

View File

@@ -13,8 +13,8 @@
},
"dependencies": {
"@formbricks/api": "*",
"@aws-sdk/client-s3": "^3.429.0",
"@aws-sdk/s3-request-presigner": "^3.429.0",
"@aws-sdk/client-s3": "3.433.0",
"@aws-sdk/s3-request-presigner": "3.433.0",
"mime": "3.0.0",
"@formbricks/database": "*",
"@formbricks/types": "*",

View File

@@ -1,9 +1,10 @@
import { ZId } from "@formbricks/types/environment";
import { validateInputs } from "../utils/validate";
import { getProduct, getProductCacheTag } from "./service";
import { getProduct } from "./service";
import { unstable_cache } from "next/cache";
import { getTeamsByUserId } from "../team/service";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { productCache } from "./cache";
export const canUserAccessProduct = async (userId: string, productId: string): Promise<boolean> =>
await unstable_cache(
@@ -18,6 +19,9 @@ export const canUserAccessProduct = async (userId: string, productId: string): P
const teamIds = (await getTeamsByUserId(userId)).map((team) => team.id);
return teamIds.includes(product.teamId);
},
[`users-${userId}-products-${productId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [getProductCacheTag(productId)] }
[`canUserAccessProduct-${userId}-${productId}`],
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [productCache.tag.byId(productId), productCache.tag.byUserId(userId)],
}
)();

View File

@@ -0,0 +1,42 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
userId?: string;
teamId?: string;
environmentId?: string;
}
export const productCache = {
tag: {
byId(id: string) {
return `product-${id}`;
},
byUserId(userId: string) {
return `users-${userId}-products`;
},
byTeamId(teamId: string) {
return `teams-${teamId}-products`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-products`;
},
},
revalidate({ id, userId, teamId, environmentId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (teamId) {
revalidateTag(this.tag.byTeamId(teamId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
},
};

View File

@@ -6,17 +6,15 @@ import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import type { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { ZProduct, ZProductUpdateInput } from "@formbricks/types/product";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { z } from "zod";
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE, IS_S3_CONFIGURED } from "../constants";
import { validateInputs } from "../utils/validate";
import { createEnvironment, getEnvironmentCacheTag, getEnvironmentsCacheTag } from "../environment/service";
import { ZOptionalNumber } from "@formbricks/types/common";
import { createEnvironment } from "../environment/service";
import { environmentCache } from "../environment/cache";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "../storage/service";
export const getProductsCacheTag = (teamId: string): string => `teams-${teamId}-products`;
export const getProductCacheTag = (environmentId: string): string => `environments-${environmentId}-product`;
const getProductCacheKey = (environmentId: string): string[] => [getProductCacheTag(environmentId)];
import { productCache } from "./cache";
const selectProduct = {
id: true,
@@ -58,49 +56,44 @@ export const getProducts = async (teamId: string, page?: number): Promise<TProdu
throw error;
}
},
[`teams-${teamId}-products`],
[`getProducts-${teamId}-${page}`],
{
tags: [getProductsCacheTag(teamId)],
tags: [productCache.tag.byTeamId(teamId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getProductByEnvironmentId = async (environmentId: string): Promise<TProduct | null> => {
if (!environmentId) {
throw new ValidationError("EnvironmentId is required");
}
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: selectProduct,
});
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getProductByEnvironmentIdCached = (environmentId: string): Promise<TProduct | null> =>
export const getProductByEnvironmentId = async (environmentId: string): Promise<TProduct | null> =>
unstable_cache(
async () => {
return await getProductByEnvironmentId(environmentId);
validateInputs([environmentId, ZId]);
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: selectProduct,
});
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
getProductCacheKey(environmentId),
[`getProductByEnvironmentId-${environmentId}`],
{
tags: getProductCacheKey(environmentId),
tags: [productCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -110,6 +103,7 @@ export const updateProduct = async (
inputProduct: Partial<TProductUpdateInput>
): Promise<TProduct> => {
validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput.partial()]);
const { environments, ...data } = inputProduct;
let updatedProduct;
try {
@@ -134,10 +128,16 @@ export const updateProduct = async (
try {
const product = ZProduct.parse(updatedProduct);
revalidateTag(getProductsCacheTag(product.teamId));
productCache.revalidate({
id: product.id,
teamId: product.teamId,
});
product.environments.forEach((environment) => {
// revalidate environment cache
revalidateTag(getProductCacheTag(environment.id));
productCache.revalidate({
environmentId: environment.id,
});
});
return product;
@@ -149,24 +149,32 @@ export const updateProduct = async (
}
};
export const getProduct = async (productId: string): Promise<TProduct | null> => {
let productPrisma;
try {
productPrisma = await prisma.product.findUnique({
where: {
id: productId,
},
select: selectProduct,
});
export const getProduct = async (productId: string): Promise<TProduct | null> =>
unstable_cache(
async () => {
let productPrisma;
try {
productPrisma = await prisma.product.findUnique({
where: {
id: productId,
},
select: selectProduct,
});
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
return productPrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getProduct-${productId}`],
{
tags: [productCache.tag.byId(productId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
};
)();
export const deleteProduct = async (productId: string): Promise<TProduct> => {
const product = await prisma.product.delete({
@@ -203,12 +211,23 @@ export const deleteProduct = async (productId: string): Promise<TProduct> => {
}
}
revalidateTag(getProductsCacheTag(product.teamId));
revalidateTag(getEnvironmentsCacheTag(product.id));
productCache.revalidate({
id: product.id,
teamId: product.teamId,
});
environmentCache.revalidate({
productId: product.id,
});
product.environments.forEach((environment) => {
// revalidate product cache
revalidateTag(getProductCacheTag(environment.id));
revalidateTag(getEnvironmentCacheTag(environment.id));
productCache.revalidate({
environmentId: environment.id,
});
environmentCache.revalidate({
id: environment.id,
});
});
}
@@ -219,6 +238,8 @@ export const createProduct = async (
teamId: string,
productInput: Partial<TProductUpdateInput>
): Promise<TProduct> => {
validateInputs([teamId, ZString], [productInput, ZProductUpdateInput.partial()]);
if (!productInput.name) {
throw new ValidationError("Product Name is required");
}

View File

@@ -0,0 +1,26 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
email?: string;
}
export const profileCache = {
tag: {
byId(id: string) {
return `profiles-${id}`;
},
byEmail(email: string) {
return `profiles-${email}`;
},
},
revalidate({ id, email }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (email) {
revalidateTag(this.tag.byEmail(email));
}
},
};

View File

@@ -3,7 +3,7 @@ import "server-only";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/memberships";
import { TMembership } from "@formbricks/types/memberships";
import {
TProfile,
TProfileCreateInput,
@@ -11,11 +11,13 @@ import {
ZProfileUpdateInput,
} from "@formbricks/types/profile";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { z } from "zod";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { deleteTeam } from "../team/service";
import { validateInputs } from "../utils/validate";
import { profileCache } from "./cache";
import { updateMembership } from "../membership/service";
const responseSelection = {
id: true,
@@ -29,18 +31,16 @@ const responseSelection = {
objective: true,
};
export const getProfileCacheTag = (userId: string): string => `profiles-${userId}`;
export const getProfileByEmailCacheTag = (email: string): string => `profiles-${email}`;
// function to retrive basic information about a user's profile
export const getProfile = async (userId: string): Promise<TProfile | null> =>
export const getProfile = async (id: string): Promise<TProfile | null> =>
unstable_cache(
async () => {
validateInputs([userId, ZId]);
validateInputs([id, ZId]);
try {
const profile = await prisma.user.findUnique({
where: {
id: userId,
id,
},
select: responseSelection,
});
@@ -58,9 +58,9 @@ export const getProfile = async (userId: string): Promise<TProfile | null> =>
throw error;
}
},
[`profiles-${userId}`],
[`getProfile-${id}`],
{
tags: [getProfileByEmailCacheTag(userId)],
tags: [profileCache.tag.byId(id)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -69,6 +69,7 @@ export const getProfileByEmail = async (email: string): Promise<TProfile | null>
unstable_cache(
async () => {
validateInputs([email, z.string().email()]);
try {
const profile = await prisma.user.findFirst({
where: {
@@ -90,28 +91,13 @@ export const getProfileByEmail = async (email: string): Promise<TProfile | null>
throw error;
}
},
[`profiles-${email}`],
[`getProfileByEmail-${email}`],
{
tags: [getProfileCacheTag(email)],
tags: [profileCache.tag.byEmail(email)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
const updateUserMembership = async (teamId: string, userId: string, role: TMembershipRole) => {
validateInputs([teamId, ZId], [userId, ZId], [role, ZMembershipRole]);
await prisma.membership.update({
where: {
userId_teamId: {
userId,
teamId,
},
},
data: {
role,
},
});
};
const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
memberships.filter((membership) => membership.role === "admin");
@@ -121,6 +107,7 @@ export const updateProfile = async (
data: Partial<TProfileUpdateInput>
): Promise<TProfile> => {
validateInputs([personId, ZId], [data, ZProfileUpdateInput.partial()]);
try {
const updatedProfile = await prisma.user.update({
where: {
@@ -130,8 +117,10 @@ export const updateProfile = async (
select: responseSelection,
});
revalidateTag(getProfileByEmailCacheTag(updatedProfile.email));
revalidateTag(getProfileCacheTag(personId));
profileCache.revalidate({
email: updatedProfile.email,
id: updatedProfile.id,
});
return updatedProfile;
} catch (error) {
@@ -143,40 +132,48 @@ export const updateProfile = async (
}
};
const deleteUser = async (userId: string): Promise<TProfile> => {
validateInputs([userId, ZId]);
const deleteUser = async (id: string): Promise<TProfile> => {
validateInputs([id, ZId]);
const profile = await prisma.user.delete({
where: {
id: userId,
id,
},
select: responseSelection,
});
revalidateTag(getProfileByEmailCacheTag(profile.email));
revalidateTag(getProfileCacheTag(userId));
profileCache.revalidate({
email: profile.email,
id,
});
return profile;
};
export const createProfile = async (data: TProfileCreateInput): Promise<TProfile> => {
validateInputs([data, ZProfileUpdateInput]);
const profile = await prisma.user.create({
data: data,
select: responseSelection,
});
revalidateTag(getProfileByEmailCacheTag(profile.email));
revalidateTag(getProfileCacheTag(profile.id));
profileCache.revalidate({
email: profile.email,
id: profile.id,
});
return profile;
};
// function to delete a user's profile including teams
export const deleteProfile = async (userId: string): Promise<TProfile> => {
validateInputs([userId, ZId]);
export const deleteProfile = async (id: string): Promise<TProfile> => {
validateInputs([id, ZId]);
try {
const currentUserMemberships = await prisma.membership.findMany({
where: {
userId: userId,
userId: id,
},
include: {
team: {
@@ -203,15 +200,13 @@ export const deleteProfile = async (userId: string): Promise<TProfile> => {
await deleteTeam(teamId);
} else if (currentUserIsTeamOwner && teamHasAtLeastOneAdmin) {
const firstAdmin = teamAdminMemberships[0];
await updateUserMembership(teamId, firstAdmin.userId, "owner");
await updateMembership(firstAdmin.userId, teamId, { role: "owner" });
} else if (currentUserIsTeamOwner) {
await deleteTeam(teamId);
}
}
revalidateTag(getProfileCacheTag(userId));
const deletedProfile = await deleteUser(userId);
const deletedProfile = await deleteUser(id);
return deletedProfile;
} catch (error) {

View File

@@ -1,21 +1,21 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
personId?: string;
id?: string;
singleUseId?: string;
surveyId?: string;
}
export const responseCache = {
tag: {
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-responses`;
},
byId(responseId: string) {
return `responses-${responseId}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-responses`;
},
byPersonId(personId: string) {
return `people-${personId}-responses`;
},

View File

@@ -1,6 +1,10 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import {
TResponse,
TResponseInput,
@@ -8,20 +12,18 @@ import {
ZResponseInput,
ZResponseUpdateInput,
} from "@formbricks/types/responses";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TTag } from "@formbricks/types/tags";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { deleteDisplayByResponseId } from "../display/service";
import { getPerson, transformPrismaPerson } from "../person/service";
import { formatResponseDateFields } from "../response/util";
import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { unstable_cache } from "next/cache";
import { deleteDisplayByResponseId } from "../display/service";
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { responseCache } from "./cache";
import { formatResponseDateFields } from "../response/util";
const responseSelection = {
id: true,
@@ -51,6 +53,19 @@ const responseSelection = {
},
},
},
tags: {
select: {
tag: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
},
},
},
},
notes: {
select: {
id: true,
@@ -67,19 +82,6 @@ const responseSelection = {
isEdited: true,
},
},
tags: {
select: {
tag: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
environmentId: true,
},
},
},
},
};
export const getResponsesByPersonId = async (
@@ -229,11 +231,15 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
};
responseCache.revalidate({
personId: response.person?.id,
id: response.id,
personId: response.person?.id,
surveyId: response.surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -277,7 +283,10 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
},
[`getResponse-${responseId}`],
{ tags: [responseCache.tag.byId(responseId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
{
tags: [responseCache.tag.byId(responseId), responseNoteCache.tag.byResponseId(responseId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!response) {
@@ -310,11 +319,15 @@ export const getResponses = async (surveyId: string, page?: number): Promise<TRe
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
const transformedResponses: TResponse[] = await Promise.all(
responses.map(async (responsePrisma) => {
return {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
@@ -363,11 +376,15 @@ export const getResponsesByEnvironmentId = async (
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}));
const transformedResponses: TResponse[] = await Promise.all(
responses.map(async (responsePrisma) => {
return {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
@@ -435,11 +452,15 @@ export const updateResponse = async (
};
responseCache.revalidate({
personId: response.person?.id,
id: response.id,
personId: response.person?.id,
surveyId: response.surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -460,19 +481,26 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
select: responseSelection,
});
const responseNotes = await getResponseNotes(responsePrisma.id);
const response: TResponse = {
...responsePrisma,
notes: responseNotes,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
deleteDisplayByResponseId(responseId, response.surveyId);
responseCache.revalidate({
personId: response.person?.id,
id: response.id,
personId: response.person?.id,
surveyId: response.surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -0,0 +1,26 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
responseId?: string;
}
export const responseNoteCache = {
tag: {
byId(id: string) {
return `responseNotes-${id}`;
},
byResponseId(responseId: string) {
return `responses-${responseId}-responseNote`;
},
},
revalidate({ id, responseId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (responseId) {
revalidateTag(this.tag.byResponseId(responseId));
}
},
};

View File

@@ -6,6 +6,12 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TResponseNote } from "@formbricks/types/responses";
import { Prisma } from "@prisma/client";
import { responseCache } from "../response/cache";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { ZString } from "@formbricks/types/common";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { responseNoteCache } from "./cache";
const select = {
id: true,
@@ -33,6 +39,8 @@ export const createResponseNote = async (
userId: string,
text: string
): Promise<TResponseNote> => {
validateInputs([responseId, ZId], [userId, ZId], [text, ZString]);
try {
const responseNote = await prisma.responseNote.create({
data: {
@@ -44,30 +52,18 @@ export const createResponseNote = async (
});
responseCache.revalidate({
id: responseId,
id: responseNote.response.id,
surveyId: responseNote.response.surveyId,
});
return responseNote;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getResponseNote = async (responseNoteId: string): Promise<TResponseNote | null> => {
try {
const responseNote = await prisma.responseNote.findUnique({
where: {
id: responseNoteId,
},
select,
responseNoteCache.revalidate({
id: responseNote.id,
responseId: responseNote.response.id,
});
return responseNote;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -76,7 +72,59 @@ export const getResponseNote = async (responseNoteId: string): Promise<TResponse
}
};
export const getResponseNote = async (responseNoteId: string): Promise<TResponseNote | null> =>
unstable_cache(
async () => {
try {
const responseNote = await prisma.responseNote.findUnique({
where: {
id: responseNoteId,
},
select,
});
return responseNote;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getResponseNote-${responseNoteId}`],
{ tags: [responseNoteCache.tag.byId(responseNoteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
export const getResponseNotes = async (responseId: string): Promise<TResponseNote[]> =>
unstable_cache(
async () => {
try {
validateInputs([responseId, ZId]);
const responseNotes = await prisma.responseNote.findMany({
where: {
responseId,
},
select,
});
return responseNotes;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getResponseNotes-${responseId}`],
{ tags: [responseNoteCache.tag.byResponseId(responseId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
export const updateResponseNote = async (responseNoteId: string, text: string): Promise<TResponseNote> => {
validateInputs([responseNoteId, ZString], [text, ZString]);
try {
const updatedResponseNote = await prisma.responseNote.update({
where: {
@@ -95,8 +143,14 @@ export const updateResponseNote = async (responseNoteId: string, text: string):
surveyId: updatedResponseNote.response.surveyId,
});
responseNoteCache.revalidate({
id: updatedResponseNote.id,
responseId: updatedResponseNote.response.id,
});
return updatedResponseNote;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -106,6 +160,8 @@ export const updateResponseNote = async (responseNoteId: string, text: string):
};
export const resolveResponseNote = async (responseNoteId: string): Promise<TResponseNote> => {
validateInputs([responseNoteId, ZString]);
try {
const responseNote = await prisma.responseNote.update({
where: {
@@ -123,8 +179,14 @@ export const resolveResponseNote = async (responseNoteId: string): Promise<TResp
surveyId: responseNote.response.surveyId,
});
responseNoteCache.revalidate({
id: responseNote.id,
responseId: responseNote.response.id,
});
return responseNote;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}

View File

@@ -0,0 +1,26 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
personId?: string;
}
export const sessionCache = {
tag: {
byId(id: string) {
return `sessions-${id}`;
},
byPersonId(personId: string) {
return `people-${personId}-sessions`;
},
},
revalidate({ id, personId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (personId) {
revalidateTag(this.tag.byPersonId(personId));
}
},
};

View File

@@ -4,14 +4,13 @@ import "server-only";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError } from "@formbricks/types/errors";
import { TSession, TSessionWithActions } from "@formbricks/types/sessions";
import { TSession } from "@formbricks/types/sessions";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { validateInputs } from "../utils/validate";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
const getSessionCacheKey = (sessionId: string): string[] => [sessionId];
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { sessionCache } from "./cache";
import { formatSessionDateFields } from "./util";
const select = {
id: true,
@@ -24,93 +23,64 @@ const select = {
const oneHour = 1000 * 60 * 60;
export const getSession = async (sessionId: string): Promise<TSession | null> => {
validateInputs([sessionId, ZId]);
try {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getSessionCached = (sessionId: string) =>
unstable_cache(
const session = await unstable_cache(
async () => {
return await getSession(sessionId);
validateInputs([sessionId, ZId]);
try {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
getSessionCacheKey(sessionId),
[`getSession-${sessionId}`],
{
tags: getSessionCacheKey(sessionId),
tags: [sessionCache.tag.byId(sessionId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSessionWithActionsOfPerson = async (
personId: string,
page?: number
): Promise<TSessionWithActions[] | null> => {
validateInputs([personId, ZId], [page, ZOptionalNumber]);
try {
const sessionsWithActionsForPerson = await prisma.session.findMany({
where: {
personId,
},
select: {
id: true,
events: {
select: {
id: true,
createdAt: true,
eventClass: {
select: {
name: true,
description: true,
type: true,
},
},
if (!session) return null;
return formatSessionDateFields(session);
};
export const getSessionCount = async (personId: string): Promise<number> =>
unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const sessionCount = await prisma.session.count({
where: {
personId,
},
},
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
if (!sessionsWithActionsForPerson) return null;
return sessionsWithActionsForPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
});
return sessionCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSessionCount-${personId}`],
{
tags: [sessionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
throw error;
}
};
export const getSessionCount = async (personId: string): Promise<number> => {
validateInputs([personId, ZId]);
try {
const sessionCount = await prisma.session.count({
where: {
personId,
},
});
return sessionCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
)();
export const createSession = async (personId: string): Promise<TSession> => {
validateInputs([personId, ZId]);
@@ -128,8 +98,10 @@ export const createSession = async (personId: string): Promise<TSession> => {
});
if (session) {
// revalidate session cache
revalidateTag(session.id);
sessionCache.revalidate({
id: session.id,
personId,
});
}
return session;
@@ -144,6 +116,7 @@ export const createSession = async (personId: string): Promise<TSession> => {
export const extendSession = async (sessionId: string): Promise<TSession> => {
validateInputs([sessionId, ZId]);
try {
const session = await prisma.session.update({
where: {
@@ -156,7 +129,10 @@ export const extendSession = async (sessionId: string): Promise<TSession> => {
});
// revalidate session cache
revalidateTag(sessionId);
sessionCache.revalidate({
id: sessionId,
personId: session.personId,
});
return session;
} catch (error) {

View File

@@ -0,0 +1,17 @@
import "server-only";
import { TSession } from "@formbricks/types/sessions";
export const formatSessionDateFields = (session: TSession): TSession => {
if (typeof session.createdAt === "string") {
session.createdAt = new Date(session.createdAt);
}
if (typeof session.updatedAt === "string") {
session.updatedAt = new Date(session.updatedAt);
}
if (typeof session.expiresAt === "string") {
session.expiresAt = new Date(session.expiresAt);
}
return session;
};

View File

@@ -175,7 +175,6 @@ export const getS3UploadSignedUrl = async (
const postConditions: PresignedPostOptions["Conditions"] = [["content-length-range", 0, maxSize]];
try {
// @ts-ignore
const { fields, url } = await createPresignedPost(s3Client, {
Expires: 10 * 60, // 10 minutes
Bucket: AWS_BUCKET_NAME,

View File

@@ -1,7 +1,8 @@
import { ZId } from "@formbricks/types/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getSurvey, getSurveyCacheTag } from "./service";
import { getSurvey } from "./service";
import { surveyCache } from "./cache";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
@@ -20,6 +21,6 @@ export const canUserAccessSurvey = async (userId: string, surveyId: string): Pro
return true;
},
[`users-${userId}-surveys-${surveyId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [getSurveyCacheTag(surveyId)] }
[`canUserAccessSurvey-${userId}-${surveyId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [surveyCache.tag.byId(surveyId)] }
)();

View File

@@ -0,0 +1,42 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
attributeClassId?: string;
actionClassId?: string;
environmentId?: string;
}
export const surveyCache = {
tag: {
byId(id: string) {
return `surveys-${id}`;
},
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-surveys`;
},
byAttributeClassId(attributeClassId: string) {
return `attributeFilters-${attributeClassId}-surveys`;
},
byActionClassId(actionClassId: string) {
return `actionClasses-${actionClassId}-surveys`;
},
},
revalidate({ id, attributeClassId, actionClassId, environmentId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (attributeClassId) {
revalidateTag(this.tag.byAttributeClassId(attributeClassId));
}
if (actionClassId) {
revalidateTag(this.tag.byActionClassId(actionClassId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -5,20 +5,16 @@ import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyAttributeFilter, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { TActionClass } from "@formbricks/types/actionClasses";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { responseCache } from "../response/cache";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { formatSurveyDateFields } from "./util";
// surveys cache key and tags
const getSurveysCacheTag = (environmentId: string): string => `environments-${environmentId}-surveys`;
// survey cache key and tags
export const getSurveyCacheTag = (surveyId: string): string => `surveys-${surveyId}`;
import { surveyCache } from "./cache";
export const selectSurvey = {
id: true,
@@ -71,10 +67,32 @@ export const selectSurvey = {
},
};
const getActionClassIdFromName = (actionClasses: TActionClass[], actionClassName: string): string => {
return actionClasses.find((actionClass) => actionClass.name === actionClassName)!.id;
};
const revalidateSurveyByActionClassId = (actionClasses: TActionClass[], actionClassNames: string[]): void => {
for (const actionClassName of actionClassNames) {
const actionClassId: string = getActionClassIdFromName(actionClasses, actionClassName);
surveyCache.revalidate({
actionClassId,
});
}
};
const revalidateSurveyByAttributeClassId = (attributeFilters: TSurveyAttributeFilter[]): void => {
for (const attributeFilter of attributeFilters) {
surveyCache.revalidate({
attributeClassId: attributeFilter.attributeClassId,
});
}
};
export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
const survey = await unstable_cache(
async () => {
validateInputs([surveyId, ZId]);
let surveyPrisma;
try {
surveyPrisma = await prisma.survey.findUnique({
@@ -103,9 +121,9 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
return transformedSurvey;
},
[`surveys-${surveyId}`],
[`getSurvey-${surveyId}`],
{
tags: [getSurveyCacheTag(surveyId)],
tags: [surveyCache.tag.byId(surveyId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -126,61 +144,91 @@ export const getSurveysByAttributeClassId = async (
attributeClassId: string,
page?: number
): Promise<TSurvey[]> => {
validateInputs([attributeClassId, ZId], [page, ZOptionalNumber]);
const surveys = await unstable_cache(
async () => {
validateInputs([attributeClassId, ZId], [page, ZOptionalNumber]);
const surveysPrisma = await prisma.survey.findMany({
where: {
attributeFilters: {
some: {
attributeClassId,
const surveysPrisma = await prisma.survey.findMany({
where: {
attributeFilters: {
some: {
attributeClassId,
},
},
},
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
};
surveys.push(transformedSurvey);
}
return surveys;
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
[`getSurveysByAttributeClassId-${attributeClassId}-${page}`],
{
tags: [surveyCache.tag.byAttributeClassId(attributeClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
};
surveys.push(transformedSurvey);
}
return surveys;
return surveys.map((survey) => ({
...survey,
...formatSurveyDateFields(survey),
}));
};
export const getSurveysByActionClassId = async (actionClassId: string, page?: number): Promise<TSurvey[]> => {
validateInputs([actionClassId, ZId], [page, ZOptionalNumber]);
const surveys = await unstable_cache(
async () => {
validateInputs([actionClassId, ZId], [page, ZOptionalNumber]);
const surveysPrisma = await prisma.survey.findMany({
where: {
triggers: {
some: {
eventClass: {
id: actionClassId,
const surveysPrisma = await prisma.survey.findMany({
where: {
triggers: {
some: {
eventClass: {
id: actionClassId,
},
},
},
},
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
};
surveys.push(transformedSurvey);
}
return surveys;
},
select: selectSurvey,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
[`getSurveysByActionClassId-${actionClassId}-${page}`],
{
tags: [surveyCache.tag.byActionClassId(actionClassId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
};
surveys.push(transformedSurvey);
}
return surveys;
return surveys.map((survey) => ({
...survey,
...formatSurveyDateFields(survey),
}));
};
export const getSurveys = async (environmentId: string, page?: number): Promise<TSurvey[]> => {
@@ -217,9 +265,9 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
}
return surveys;
},
[`environments-${environmentId}-surveys`],
[`getSurveys-${environmentId}-${page}`],
{
tags: [getSurveysCacheTag(environmentId)],
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -250,6 +298,7 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
if (triggers) {
const newTriggers: string[] = [];
const removedTriggers: string[] = [];
// find added triggers
for (const trigger of triggers) {
if (!trigger) {
@@ -261,6 +310,7 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
newTriggers.push(trigger);
}
}
// find removed triggers
for (const trigger of currentSurvey.triggers) {
if (triggers.find((t: any) => t === trigger)) {
@@ -274,7 +324,7 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
data.triggers = {
...(data.triggers || []),
create: newTriggers.map((trigger) => ({
eventClassId: actionClasses.find((actionClass) => actionClass.name === trigger)!.id,
eventClassId: getActionClassIdFromName(actionClasses, trigger),
})),
};
}
@@ -284,23 +334,26 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
...(data.triggers || []),
deleteMany: {
eventClassId: {
in: removedTriggers.map(
(trigger) => actionClasses.find((actionClass) => actionClass.name === trigger)!.id
),
in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)),
},
},
};
}
// Revalidation for newly added/removed actionClassId
revalidateSurveyByActionClassId(actionClasses, [...newTriggers, ...removedTriggers]);
}
if (attributeFilters) {
const newFilters: TSurveyAttributeFilter[] = [];
const removedFilterIds: string[] = [];
const removedFilters: TSurveyAttributeFilter[] = [];
// find added attribute filters
for (const attributeFilter of attributeFilters) {
if (!attributeFilter.attributeClassId || !attributeFilter.condition || !attributeFilter.value) {
continue;
}
if (
currentSurvey.attributeFilters.find(
(f) =>
@@ -330,9 +383,14 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
) {
continue;
} else {
removedFilterIds.push(attributeFilter.attributeClassId);
removedFilters.push({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
});
}
}
// create new attribute filters
if (newFilters.length > 0) {
data.attributeFilters = {
@@ -344,19 +402,21 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
})),
};
}
// delete removed triggers
if (removedFilterIds.length > 0) {
// delete removed attribute filter
if (removedFilters.length > 0) {
// delete all attribute filters that match the removed attribute classes
await Promise.all(
removedFilterIds.map(async (attributeClassId) => {
removedFilters.map(async (attributeFilter) => {
await prisma.surveyAttributeFilter.deleteMany({
where: {
attributeClassId,
attributeClassId: attributeFilter.attributeClassId,
},
});
})
);
}
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
}
data = {
@@ -376,10 +436,10 @@ export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
attributeFilters: updatedSurvey.attributeFilters ? updatedSurvey.attributeFilters : [], // Include attributeFilters from updatedSurvey
};
// console.log("++++m", modifiedSurvey);
revalidateTag(getSurveysCacheTag(modifiedSurvey.environmentId));
revalidateTag(getSurveyCacheTag(modifiedSurvey.id));
surveyCache.revalidate({
id: modifiedSurvey.id,
environmentId: modifiedSurvey.environmentId,
});
return modifiedSurvey;
} catch (error) {
@@ -402,13 +462,27 @@ export async function deleteSurvey(surveyId: string) {
select: selectSurvey,
});
revalidateTag(getSurveysCacheTag(deletedSurvey.environmentId));
revalidateTag(getSurveyCacheTag(surveyId));
responseCache.revalidate({
surveyId,
environmentId: deletedSurvey.environmentId,
});
surveyCache.revalidate({
id: deletedSurvey.id,
environmentId: deletedSurvey.environmentId,
});
// Revalidate triggers by actionClassId
deletedSurvey.triggers.forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.eventClass.id,
});
});
// Revalidate surveys by attributeClassId
deletedSurvey.attributeFilters.forEach((attributeFilter) => {
surveyCache.revalidate({
attributeClassId: attributeFilter.attributeClassId,
});
});
return deletedSurvey;
}
@@ -416,6 +490,15 @@ export async function deleteSurvey(surveyId: string) {
export async function createSurvey(environmentId: string, surveyBody: TSurveyInput): Promise<TSurvey> {
validateInputs([environmentId, ZId]);
if (surveyBody.attributeFilters) {
revalidateSurveyByAttributeClassId(surveyBody.attributeFilters);
}
if (surveyBody.triggers) {
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
}
// TODO: Create with triggers & attributeFilters
delete surveyBody.triggers;
delete surveyBody.attributeFilters;
@@ -442,8 +525,10 @@ export async function createSurvey(environmentId: string, surveyBody: TSurveyInp
captureTelemetry("survey created");
revalidateTag(getSurveysCacheTag(environmentId));
revalidateTag(getSurveyCacheTag(survey.id));
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,
});
return transformedSurvey;
}
@@ -456,6 +541,11 @@ export async function duplicateSurvey(environmentId: string, surveyId: string) {
}
const actionClasses = await getActionClasses(environmentId);
const newAttributeFilters = existingSurvey.attributeFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
}));
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
@@ -469,15 +559,11 @@ export async function duplicateSurvey(environmentId: string, surveyId: string) {
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: existingSurvey.triggers.map((trigger) => ({
eventClassId: actionClasses.find((actionClass) => actionClass.name === trigger)!.id,
eventClassId: getActionClassIdFromName(actionClasses, trigger),
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
create: newAttributeFilters,
},
environment: {
connect: {
@@ -502,8 +588,16 @@ export async function duplicateSurvey(environmentId: string, surveyId: string) {
},
});
revalidateTag(getSurveysCacheTag(environmentId));
revalidateTag(getSurveyCacheTag(surveyId));
surveyCache.revalidate({
id: newSurvey.id,
environmentId: newSurvey.environmentId,
});
// Revalidate surveys by actionClassId
revalidateSurveyByActionClassId(actionClasses, existingSurvey.triggers);
// Revalidate surveys by attributeClassId
revalidateSurveyByAttributeClassId(newAttributeFilters);
return newSurvey;
}

View File

@@ -5,7 +5,7 @@ import { unstable_cache } from "next/cache";
import { ZId } from "@formbricks/types/environment";
import { canUserAccessResponse } from "../response/auth";
import { canUserAccessTag } from "../tag/auth";
import { getTagOnResponseCacheTag } from "./service";
import { tagOnResponseCache } from "./cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
export const canUserAccessTagOnResponse = async (
@@ -23,5 +23,8 @@ export const canUserAccessTagOnResponse = async (
return isAuthorizedForTag && isAuthorizedForResponse;
},
[`users-${userId}-tagOnResponse-${tagId}-${responseId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [getTagOnResponseCacheTag(tagId, responseId)] }
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [tagOnResponseCache.tag.byResponseIdAndTagId(responseId, tagId)],
}
)();

View File

@@ -0,0 +1,27 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
tagId?: string;
responseId?: string;
environmentId?: string;
}
export const tagOnResponseCache = {
tag: {
byResponseIdAndTagId(responseId: string, tagId: string) {
return `responses-${responseId}-tagOnResponses-${tagId}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-tagOnResponses`;
},
},
revalidate({ tagId, responseId, environmentId }: RevalidateProps): void {
if (responseId && tagId) {
revalidateTag(this.tag.byResponseIdAndTagId(responseId, tagId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -3,9 +3,19 @@ import "server-only";
import { prisma } from "@formbricks/database";
import { TTagsCount, TTagsOnResponses } from "@formbricks/types/tags";
import { responseCache } from "../response/cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { tagOnResponseCache } from "./cache";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
export const getTagOnResponseCacheTag = (tagId: string, responseId: string) =>
`tagsOnResponse-${tagId}-${responseId}`;
const selectTagsOnResponse = {
tag: {
select: {
environmentId: true,
},
},
};
export const addTagToRespone = async (responseId: string, tagId: string): Promise<TTagsOnResponses> => {
try {
@@ -14,12 +24,23 @@ export const addTagToRespone = async (responseId: string, tagId: string): Promis
responseId,
tagId,
},
select: selectTagsOnResponse,
});
responseCache.revalidate({
id: responseId,
});
return tagOnResponse;
tagOnResponseCache.revalidate({
tagId,
responseId,
environmentId: tagOnResponse.tag.environmentId,
});
return {
responseId,
tagId,
};
} catch (error) {
throw error;
}
@@ -34,28 +55,58 @@ export const deleteTagOnResponse = async (responseId: string, tagId: string): Pr
tagId,
},
},
select: selectTagsOnResponse,
});
responseCache.revalidate({
id: responseId,
});
return deletedTag;
} catch (error) {
throw error;
}
};
export const getTagsOnResponsesCount = async (): Promise<TTagsCount> => {
try {
const tagsCount = await prisma.tagsOnResponses.groupBy({
by: ["tagId"],
_count: {
_all: true,
},
tagOnResponseCache.revalidate({
tagId,
responseId,
environmentId: deletedTag.tag.environmentId,
});
return tagsCount.map((tagCount) => ({ tagId: tagCount.tagId, count: tagCount._count._all }));
return {
tagId,
responseId,
};
} catch (error) {
throw error;
}
};
export const getTagsOnResponsesCount = async (environmentId: string): Promise<TTagsCount> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const tagsCount = await prisma.tagsOnResponses.groupBy({
by: ["tagId"],
where: {
response: {
survey: {
environment: {
id: environmentId,
},
},
},
},
_count: {
_all: true,
},
});
return tagsCount.map((tagCount) => ({ tagId: tagCount.tagId, count: tagCount._count._all }));
} catch (error) {
throw error;
}
},
[`getTagsOnResponsesCount-${environmentId}`],
{
tags: [tagOnResponseCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();

View File

@@ -0,0 +1,34 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
userId?: string;
environmentId?: string;
}
export const teamCache = {
tag: {
byId(id: string) {
return `teams-${id}`;
},
byUserId(userId: string) {
return `users-${userId}-teams`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-teams`;
},
},
revalidate({ id, userId, environmentId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -5,11 +5,12 @@ import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TTeam, TTeamUpdateInput, ZTeamUpdateInput } from "@formbricks/types/teams";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants";
import { getEnvironmentCacheTag } from "../environment/service";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { validateInputs } from "../utils/validate";
import { environmentCache } from "../environment/cache";
import { teamCache } from "./cache";
export const select = {
id: true,
@@ -20,9 +21,6 @@ export const select = {
stripeCustomerId: true,
};
export const getTeamsByUserIdCacheTag = (userId: string) => `users-${userId}-teams`;
export const getTeamByEnvironmentIdCacheTag = (environmentId: string) => `environments-${environmentId}-team`;
export const getTeamsByUserId = async (userId: string, page?: number): Promise<TTeam[]> =>
unstable_cache(
async () => {
@@ -41,7 +39,6 @@ export const getTeamsByUserId = async (userId: string, page?: number): Promise<T
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
revalidateTag(getTeamsByUserIdCacheTag(userId));
return teams;
} catch (error) {
@@ -52,9 +49,9 @@ export const getTeamsByUserId = async (userId: string, page?: number): Promise<T
throw error;
}
},
[`users-${userId}-teams`],
[`getTeamsByUserId-${userId}-${page}`],
{
tags: [getTeamsByUserIdCacheTag(userId)],
tags: [teamCache.tag.byUserId(userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -79,7 +76,6 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise<TTe
},
select: { ...select, memberships: true }, // include memberships
});
revalidateTag(getTeamByEnvironmentIdCacheTag(environmentId));
return team;
} catch (error) {
@@ -91,9 +87,9 @@ export const getTeamByEnvironmentId = async (environmentId: string): Promise<TTe
throw error;
}
},
[`environments-${environmentId}-team`],
[`getTeamByEnvironmentId-${environmentId}`],
{
tags: [getTeamByEnvironmentIdCacheTag(environmentId)],
tags: [teamCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -107,6 +103,10 @@ export const createTeam = async (teamInput: TTeamUpdateInput): Promise<TTeam> =>
select,
});
teamCache.revalidate({
id: team.id,
});
return team;
} catch (error) {
throw error;
@@ -125,13 +125,17 @@ export const updateTeam = async (teamId: string, data: Partial<TTeamUpdateInput>
// revalidate cache for members
updatedTeam?.memberships.forEach((membership) => {
revalidateTag(getTeamsByUserIdCacheTag(membership.userId));
teamCache.revalidate({
userId: membership.userId,
});
});
// revalidate cache for environments
updatedTeam?.products.forEach((product) => {
product.environments.forEach((environment) => {
revalidateTag(getTeamByEnvironmentIdCacheTag(environment.id));
teamCache.revalidate({
environmentId: environment.id,
});
});
});
@@ -141,6 +145,10 @@ export const updateTeam = async (teamId: string, data: Partial<TTeamUpdateInput>
products: undefined,
};
teamCache.revalidate({
id: team.id,
});
return team;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
@@ -163,14 +171,21 @@ export const deleteTeam = async (teamId: string): Promise<TTeam> => {
// revalidate cache for members
deletedTeam?.memberships.forEach((membership) => {
revalidateTag(getTeamsByUserIdCacheTag(membership.userId));
teamCache.revalidate({
userId: membership.userId,
});
});
// revalidate cache for environments
deletedTeam?.products.forEach((product) => {
product.environments.forEach((environment) => {
revalidateTag(getTeamByEnvironmentIdCacheTag(environment.id));
revalidateTag(getEnvironmentCacheTag(environment.id));
environmentCache.revalidate({
id: environment.id,
});
teamCache.revalidate({
environmentId: environment.id,
});
});
});
@@ -180,6 +195,10 @@ export const deleteTeam = async (teamId: string): Promise<TTeam> => {
products: undefined,
};
teamCache.revalidate({
id: team.id,
});
return team;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -5,53 +5,65 @@ import { Prisma } from "@prisma/client";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { unstable_cache } from "next/cache";
import { teamCache } from "../team/cache";
export const getTeamDetails = async (
environmentId: string
): Promise<{ teamId: string; teamOwnerId: string | undefined }> => {
validateInputs([environmentId, ZId]);
try {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
): Promise<{ teamId: string; teamOwnerId: string | undefined }> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
team: {
product: {
select: {
id: true,
memberships: {
team: {
select: {
userId: true,
role: true,
id: true,
memberships: {
select: {
userId: true,
role: true,
},
},
},
},
},
},
},
},
},
});
});
if (!environment) {
throw new ResourceNotFoundError("Environment", environmentId);
if (!environment) {
throw new ResourceNotFoundError("Environment", environmentId);
}
const teamId: string = environment.product.team.id;
// find team owner
const teamOwnerId: string | undefined = environment.product.team.memberships.find(
(m) => m.role === "owner"
)?.userId;
return {
teamId: teamId,
teamOwnerId: teamOwnerId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamDetails-${environmentId}`],
{
tags: [teamCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
const teamId: string = environment.product.team.id;
// find team owner
const teamOwnerId: string | undefined = environment.product.team.memberships.find(
(m) => m.role === "owner"
)?.userId;
return {
teamId: teamId,
teamOwnerId: teamOwnerId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
)();

View File

@@ -4,6 +4,7 @@ import { getWebhook } from "./service";
import { unstable_cache } from "next/cache";
import { ZId } from "@formbricks/types/environment";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { webhookCache } from "./cache";
export const canUserAccessWebhook = async (userId: string, webhookId: string): Promise<boolean> =>
await unstable_cache(
@@ -18,8 +19,9 @@ export const canUserAccessWebhook = async (userId: string, webhookId: string): P
return true;
},
[`${userId}-${webhookId}`],
[`canUserAccessWebhook-${userId}-${webhookId}`],
{
tags: [webhookCache.tag.byId(webhookId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();

View File

@@ -0,0 +1,35 @@
import { revalidateTag } from "next/cache";
import { TWebhookInput } from "@formbricks/types/webhooks";
interface RevalidateProps {
id?: string;
environmentId?: string;
source?: TWebhookInput["source"];
}
export const webhookCache = {
tag: {
byId(id: string) {
return `webhooks-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-webhooks`;
},
byEnvironmentIdAndSource(environmentId: string, source: TWebhookInput["source"]) {
return `environments-${environmentId}-sources-${source}-webhooks`;
},
},
revalidate({ id, environmentId, source }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (environmentId && source) {
revalidateTag(this.tag.byEnvironmentIdAndSource(environmentId, source));
}
},
};

View File

@@ -7,60 +7,89 @@ import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { ResourceNotFoundError, DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ITEMS_PER_PAGE } from "../constants";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { webhookCache } from "./cache";
import { unstable_cache } from "next/cache";
export const getWebhooks = async (environmentId: string, page?: number): Promise<TWebhook[]> => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
export const getWebhooks = async (environmentId: string, page?: number): Promise<TWebhook[]> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return webhooks;
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
};
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return webhooks;
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
},
[`getWebhooks-${environmentId}-${page}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getCountOfWebhooksBasedOnSource = async (
environmentId: string,
source: TWebhookInput["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, ZId]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
};
): Promise<number> =>
unstable_cache(
async () => {
validateInputs([environmentId, ZId], [source, ZId]);
export const getWebhook = async (id: string): Promise<TWebhook | null> => {
validateInputs([id, ZId]);
const webhook = await prisma.webhook.findUnique({
where: {
id,
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
throw new DatabaseError(`Database error when fetching webhooks for environment ${environmentId}`);
}
},
});
return webhook;
};
[`getCountOfWebhooksBasedOnSource-${environmentId}-${source}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getWebhook = async (id: string): Promise<TWebhook | null> =>
unstable_cache(
async () => {
validateInputs([id, ZId]);
const webhook = await prisma.webhook.findUnique({
where: {
id,
},
});
return webhook;
},
[`getWebhook-${id}`],
{
tags: [webhookCache.tag.byId(id)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const createWebhook = async (
environmentId: string,
webhookInput: TWebhookInput
): Promise<TWebhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try {
let createdWebhook = await prisma.webhook.create({
const createdWebhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
@@ -71,6 +100,13 @@ export const createWebhook = async (
},
},
});
webhookCache.revalidate({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
return createdWebhook;
} catch (error) {
if (!(error instanceof InvalidInputError)) {
@@ -87,7 +123,7 @@ export const updateWebhook = async (
): Promise<TWebhook> => {
validateInputs([environmentId, ZId], [webhookId, ZId], [webhookInput, ZWebhookInput]);
try {
const webhook = await prisma.webhook.update({
const updatedWebhook = await prisma.webhook.update({
where: {
id: webhookId,
},
@@ -98,7 +134,14 @@ export const updateWebhook = async (
surveyIds: webhookInput.surveyIds || [],
},
});
return webhook;
webhookCache.revalidate({
id: updatedWebhook.id,
environmentId: updatedWebhook.environmentId,
source: updatedWebhook.source,
});
return updatedWebhook;
} catch (error) {
throw new DatabaseError(
`Database error when updating webhook with ID ${webhookId} for environment ${environmentId}`
@@ -108,12 +151,20 @@ export const updateWebhook = async (
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
validateInputs([id, ZId]);
try {
let deletedWebhook = await prisma.webhook.delete({
where: {
id,
},
});
webhookCache.revalidate({
id: deletedWebhook.id,
environmentId: deletedWebhook.environmentId,
source: deletedWebhook.source,
});
return deletedWebhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {

View File

@@ -7,7 +7,7 @@ import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface MultipleChoiceSingleProps {
interface MultipleChoiceMultiProps {
question: TSurveyMultipleChoiceMultiQuestion;
value: string | number | string[];
onChange: (responseData: TResponseData) => void;
@@ -18,7 +18,7 @@ interface MultipleChoiceSingleProps {
brandColor: string;
}
export default function MultipleChoiceSingleQuestion({
export default function MultipleChoiceMultiQuestion({
question,
value,
onChange,
@@ -27,7 +27,7 @@ export default function MultipleChoiceSingleQuestion({
isFirstQuestion,
isLastQuestion,
brandColor,
}: MultipleChoiceSingleProps) {
}: MultipleChoiceMultiProps) {
const getChoicesWithoutOtherLabels = useCallback(
() => question.choices.filter((choice) => choice.id !== "other").map((item) => item.label),
[question]

View File

@@ -0,0 +1,178 @@
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
import { useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface PictureSelectionProps {
question: TSurveyPictureSelectionQuestion;
value: string | number | string[];
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData) => void;
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function PictureSelectionQuestion({
question,
value,
onChange,
onSubmit,
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: PictureSelectionProps) {
const addItem = (item: string) => {
let values: string[] = [];
if (question.allowMulti) {
if (Array.isArray(value)) {
values = [...value, item];
} else {
values = [item];
}
} else {
values = [item];
}
return onChange({ [question.id]: values });
};
const removeItem = (item: string) => {
let values: string[] = [];
if (question.allowMulti) {
if (Array.isArray(value)) {
values = value.filter((i) => i !== item);
} else {
values = [];
}
} else {
values = [];
}
return onChange({ [question.id]: values });
};
const handleChange = (id: string) => {
if (Array.isArray(value) && value.includes(id)) {
removeItem(id);
} else {
addItem(id);
}
};
useEffect(() => {
if (!question.allowMulti && Array.isArray(value) && value.length > 1) {
onChange({ [question.id]: [] });
}
}, [question.allowMulti]);
const questionChoices = question.choices;
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({ [question.id]: value });
}}
className="w-full">
{question.imageUrl && (
<div className="my-4 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={question.imageUrl} alt="question-image" className={"my-4 rounded-md"} />
</div>
)}
<Headline headline={question.headline} questionId={question.id} required={question.required} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="relative grid max-h-[42vh] grid-cols-2 gap-x-5 gap-y-4 overflow-y-auto rounded-md bg-white pr-2.5">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}
tabIndex={idx + 1}
htmlFor={choice.id}
onKeyDown={(e) => {
if (e.key == "Enter") {
handleChange(choice.id);
}
}}
style={{
borderColor:
Array.isArray(value) && value.includes(choice.id) ? brandColor : "border-slate-400",
color: brandColor,
}}
onClick={() => handleChange(choice.id)}
className={cn(
Array.isArray(value) && value.includes(choice.id)
? `z-10 border-4 shadow-xl focus:border-4`
: "",
"relative box-border inline-block h-28 w-full overflow-hidden rounded-xl border border-slate-400 focus:border-slate-600 focus:bg-slate-50 focus:outline-none"
)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={choice.imageUrl}
id={choice.id}
alt={choice.imageUrl.split("/").pop()}
className="h-full w-full object-cover"
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabindex={-1}
checked={Array.isArray(value) && value.includes(choice.id)}
style={{ borderColor: brandColor, color: brandColor }}
className="pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 rounded border border-slate-400"
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${choice.id}-radio`}
type="radio"
tabindex={-1}
checked={Array.isArray(value) && value.includes(choice.id)}
style={{ borderColor: brandColor, color: brandColor }}
className="pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 "
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
/>
)}
</label>
))}
</div>
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
{!isFirstQuestion && (
<BackButton
tabIndex={questionChoices.length + 3}
backButtonLabel={question.backButtonLabel}
onClick={onBack}
/>
)}
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>
</form>
);
}

View File

@@ -8,6 +8,7 @@ import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import NPSQuestion from "./NPSQuestion";
import OpenTextQuestion from "./OpenTextQuestion";
import RatingQuestion from "./RatingQuestion";
import PictureSelectionQuestion from "./PictureSelectionQuestion";
interface QuestionConditionalProps {
question: TSurveyQuestion;
@@ -99,7 +100,7 @@ export default function QuestionConditional({
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === "consent" ? (
) : question.type === TSurveyQuestionType.Consent ? (
<ConsentQuestion
question={question}
value={value}
@@ -110,5 +111,16 @@ export default function QuestionConditional({
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PictureSelectionQuestion
question={question}
value={value}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : null;
}

View File

@@ -34,8 +34,12 @@ export function Survey({
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (activeQuestionId === "start" && !survey.welcomeCard.enabled) {
setQuestionId(survey?.questions[0]?.id);
return;
}
setQuestionId(activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id));
}, [activeQuestionId, survey.questions]);
}, [activeQuestionId, survey.questions, survey.welcomeCard.enabled]);
useEffect(() => {
// scroll to top when question changes
@@ -44,20 +48,19 @@ export function Survey({
}
}, [questionId]);
// call onDisplay when component is mounted
useEffect(() => {
// call onDisplay when component is mounted
onDisplay();
if (prefillResponseData) {
onSubmit(prefillResponseData, true);
}
}, []);
let currIdx = currentQuestionIndex;
let currQues = currentQuestion;
function getNextQuestionId(data: TResponseData, isFromPrefilling: Boolean = false): string {
const questions = survey.questions;
const responseValue = data[questionId];
let currIdx = currentQuestionIndex;
let currQues = currentQuestion;
if (questionId === "start") {
if (!isFromPrefilling) {
return questions[0]?.id || "end";
@@ -66,7 +69,6 @@ export function Survey({
currQues = questions[0];
}
}
if (currIdx === -1) throw new Error("Question not found");
if (currQues?.logic && currQues?.logic.length > 0) {
@@ -111,57 +113,68 @@ export function Survey({
setHistory(newHistory);
} else {
// otherwise go back to previous question in array
prevQuestionId = survey.questions[currentQuestionIndex - 1]?.id;
prevQuestionId = survey.questions[currIdx - 1]?.id;
}
if (!prevQuestionId) throw new Error("Question not found");
setQuestionId(prevQuestionId);
onActiveQuestionChange(prevQuestionId);
};
function getCardContent() {
if (questionId === "start" && survey.welcomeCard.enabled) {
return (
<WelcomeCard
headline={survey.welcomeCard.headline}
html={survey.welcomeCard.html}
fileUrl={survey.welcomeCard.fileUrl}
buttonLabel={survey.welcomeCard.buttonLabel}
timeToFinish={survey.welcomeCard.timeToFinish}
brandColor={brandColor}
onSubmit={onSubmit}
/>
);
} else if (questionId === "end" && survey.thankYouCard.enabled) {
return (
<ThankYouCard
headline={survey.thankYouCard.headline}
subheader={survey.thankYouCard.subheader}
brandColor={brandColor}
redirectUrl={survey.redirectUrl}
isRedirectDisabled={isRedirectDisabled}
/>
);
} else {
const currQues = survey.questions.find((q) => q.id === questionId);
return (
currQues && (
<QuestionConditional
question={currQues}
value={responseData[currQues.id]}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={
history && prefillResponseData
? history[history.length - 1] === survey.questions[0].id
: currQues.id === survey?.questions[0]?.id
}
isLastQuestion={currQues.id === survey.questions[survey.questions.length - 1].id}
brandColor={brandColor}
/>
)
);
}
}
return (
<>
<AutoCloseWrapper survey={survey} brandColor={brandColor} onClose={onClose}>
<div className="flex h-full w-full flex-col justify-between rounded-2xl bg-white px-6 pb-3 pt-6">
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
{questionId === "start" && survey.welcomeCard.enabled ? (
<WelcomeCard
headline={survey.welcomeCard.headline}
html={survey.welcomeCard.html}
fileUrl={survey.welcomeCard.fileUrl}
buttonLabel={survey.welcomeCard.buttonLabel}
timeToFinish={survey.welcomeCard.timeToFinish}
brandColor={brandColor}
onSubmit={onSubmit}
/>
) : questionId === "end" && survey.thankYouCard.enabled ? (
<ThankYouCard
headline={survey.thankYouCard.headline}
subheader={survey.thankYouCard.subheader}
brandColor={brandColor}
redirectUrl={survey.redirectUrl}
isRedirectDisabled={isRedirectDisabled}
/>
{survey.questions.length === 0 && !survey.welcomeCard.enabled && !survey.thankYouCard.enabled ? (
// Handle the case when there are no questions and both welcome and thank you cards are disabled
<div>No questions available.</div>
) : (
survey.questions.map(
(question, idx) =>
questionId === question.id && (
<QuestionConditional
question={question}
value={responseData[question.id]}
onChange={onChange}
onSubmit={onSubmit}
onBack={onBack}
isFirstQuestion={
// if prefillResponseData is provided, check if we're on the first "real" question
history && prefillResponseData
? history[history.length - 1] === survey.questions[0].id
: idx === 0
}
isLastQuestion={idx === survey.questions.length - 1}
brandColor={brandColor}
/>
)
)
getCardContent()
)}
</div>
<div className="mt-8">

View File

@@ -14,3 +14,29 @@ export const ZBgColor =
export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]);
export type TPlacement = z.infer<typeof ZPlacement>;
export const ZAllowedFileExtensions = z.enum([
"png",
"jpeg",
"jpg",
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"plain",
"csv",
"mp4",
"mov",
"avi",
"mkv",
"webm",
"zip",
"rar",
"7z",
"tar",
]);
export type TAllowedFileExtensions = z.infer<typeof ZAllowedFileExtensions>;

View File

@@ -16,6 +16,7 @@ export enum TSurveyQuestionType {
CTA = "cta",
Rating = "rating",
Consent = "consent",
PictureSelection = "pictureSelection",
}
export const ZSurveyWelcomeCard = z.object({
@@ -90,6 +91,11 @@ export const ZSurveyChoice = z.object({
label: z.string(),
});
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: z.string(),
});
export type TSurveyChoice = z.infer<typeof ZSurveyChoice>;
export const ZSurveyLogicCondition = z.enum([
@@ -173,6 +179,11 @@ const ZSurveyRatingLogic = ZSurveyLogicBase.extend({
value: z.union([z.string(), z.number()]).optional(),
});
const ZSurveyPictureSelectionLogic = ZSurveyLogicBase.extend({
condition: z.enum(["submitted", "skipped"]).optional(),
value: z.undefined(),
});
export const ZSurveyLogic = z.union([
ZSurveyOpenTextLogic,
ZSurveyConsentLogic,
@@ -181,6 +192,7 @@ export const ZSurveyLogic = z.union([
ZSurveyNPSLogic,
ZSurveyCTALogic,
ZSurveyRatingLogic,
ZSurveyPictureSelectionLogic,
]);
export type TSurveyLogic = z.infer<typeof ZSurveyLogic>;
@@ -284,6 +296,15 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionType.PictureSelection),
allowMulti: z.boolean().optional().default(false),
choices: z.array(ZSurveyPictureChoice),
logic: z.array(ZSurveyPictureSelectionLogic).optional(),
});
export type TSurveyPictureSelectionQuestion = z.infer<typeof ZSurveyPictureSelectionQuestion>;
export const ZSurveyQuestion = z.union([
// ZSurveyWelcomeQuestion,
ZSurveyOpenTextQuestion,
@@ -293,6 +314,7 @@ export const ZSurveyQuestion = z.union([
ZSurveyNPSQuestion,
ZSurveyCTAQuestion,
ZSurveyRatingQuestion,
ZSurveyPictureSelectionQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
@@ -387,6 +409,7 @@ export const ZSurveyTSurveyQuestionType = z.union([
z.literal("cta"),
z.literal("rating"),
z.literal("consent"),
z.literal("pictureSelection"),
]);
export type TSurveyTSurveyQuestionType = z.infer<typeof ZSurveyTSurveyQuestionType>;

View File

@@ -1,31 +1,97 @@
"use client";
import { PhotoIcon, TrashIcon } from "@heroicons/react/24/outline";
import { cn } from "@formbricks/lib/cn";
import { TAllowedFileExtensions } from "@formbricks/types/common";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
import { FileIcon } from "lucide-react";
import { useRef, useState } from "react";
import Image from "next/image";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { uploadFile } from "./lib/fileUpload";
import { cn } from "@formbricks/lib/cn";
import { Button } from "../Button";
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
const isImage = (name: string) => {
return allowedFileTypesForPreview.includes(name.split(".").pop() as TAllowedFileExtensions);
};
interface FileInputProps {
allowedFileExtensions: string[];
id: string;
allowedFileExtensions: TAllowedFileExtensions[];
environmentId: string | undefined;
onFileUpload: (uploadedUrl: string | undefined) => void;
fileUrl: string | undefined;
onFileUpload: (uploadedUrl: string[] | undefined) => void;
fileUrl?: string | string[];
multiple?: boolean;
}
interface SelectedFile {
url: string;
name: string;
uploaded: Boolean;
}
const FileInput: React.FC<FileInputProps> = ({
id,
allowedFileExtensions,
environmentId,
onFileUpload,
fileUrl,
multiple = false,
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploaded, setIsUploaded] = useState<boolean>(!!fileUrl);
const [isError, setIsError] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
const handleUpload = async (files: File[]) => {
if (!multiple && files.length > 1) {
files = [files[0]];
toast.error("Only one file is allowed");
}
const allowedFiles = files.filter(
(file) =>
file &&
file.type &&
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
);
if (allowedFiles.length < files.length) {
if (allowedFiles.length === 0) {
toast.error("No files are supported");
return;
}
toast.error("Some files are not supported");
}
setSelectedFiles(
allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false }))
);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length === 0) {
toast.error("No files were uploaded");
} else {
toast.error("Some files failed to upload");
}
}
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(file.value.url);
}
});
if (uploadedUrls.length === 0) {
setSelectedFiles([]);
return;
}
onFileUpload(uploadedUrls);
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
@@ -33,181 +99,270 @@ const FileInput: React.FC<FileInputProps> = ({
e.dataTransfer.dropEffect = "copy";
};
const handleFileChange = async (file: File) => {
if (
file &&
file.type &&
allowedFileExtensions.includes(file.type.substring(file.type.lastIndexOf("/") + 1))
) {
setIsUploaded(false);
setSelectedFile(file);
const response = await uploadFile(file, allowedFileExtensions, environmentId);
if (response.uploaded) {
setIsUploaded(true);
onFileUpload(response.url);
}
} else {
toast.error("File not supported");
}
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
await handleFileChange(file);
const files = Array.from(e.dataTransfer.files);
handleUpload(files);
};
const handleFileUpload = async (params: {
file: File;
allowedFileExtensions: string[];
environmentId: string | undefined;
}) => {
setIsUploaded(false);
setIsError(false);
setSelectedFile(params.file);
const handleRemove = async (idx: number) => {
const newFileUrl = selectedFiles.filter((_, i) => i !== idx).map((file) => file.url);
onFileUpload(newFileUrl);
};
try {
let response = await uploadFile(params.file, params.allowedFileExtensions, params.environmentId);
setIsUploaded(true);
onFileUpload(response.url);
} catch (error: any) {
setIsUploaded(false);
setSelectedFile(null);
setIsError(true);
toast.error("Something went wrong.");
const handleUploadMoreDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
handleUploadMore(files);
};
const handleUploadMore = async (files: File[]) => {
let filesToUpload: File[] = files;
const allowedFiles = filesToUpload.filter(
(file) =>
file &&
file.type &&
allowedFileExtensions.includes(file.name.split(".").pop() as TAllowedFileExtensions)
);
if (allowedFiles.length < filesToUpload.length) {
if (allowedFiles.length === 0) {
toast.error("No files are supported");
return;
}
toast.error("Some files are not supported");
}
setSelectedFiles((prevFiles) => [
...prevFiles,
...allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false })),
]);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length === 0) {
toast.error("No files were uploaded");
} else {
toast.error("Some files failed to upload");
}
}
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(file.value.url);
}
});
const prevUrls = Array.isArray(fileUrl) ? fileUrl : fileUrl ? [fileUrl] : [];
onFileUpload([...prevUrls, ...uploadedUrls]);
};
useEffect(() => {
const getSelectedFiles = () => {
if (fileUrl && typeof fileUrl === "string") {
return [{ url: fileUrl, name: fileUrl.split("/").pop() || "", uploaded: true }];
} else if (fileUrl && Array.isArray(fileUrl)) {
return fileUrl.map((url) => ({ url, name: url.split("/").pop() || "", uploaded: true }));
} else {
return [];
}
};
setSelectedFiles(getSelectedFiles());
}, [fileUrl]);
return (
<label
htmlFor="selectedFile"
className={cn(
"relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-600 dark:hover:bg-slate-800",
isError && "border-red-500"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
{isUploaded && fileUrl ? (
<>
<div className="absolute inset-0 mr-4 mt-2 flex items-start justify-end gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 bg-opacity-50 text-slate-800 hover:bg-slate-200/50 hover:text-slate-900">
<label htmlFor="modifyFile">
<PhotoIcon className="h-5 cursor-pointer text-slate-700 hover:text-slate-900" />
<div className="w-full cursor-default">
{selectedFiles.length > 0 ? (
multiple ? (
<div className="flex flex-wrap gap-2">
{selectedFiles.map((file, idx) => (
<>
{isImage(file.name) ? (
<div className="relative h-24 w-40 overflow-hidden rounded-lg">
<Image
src={file.url}
alt={file.name}
fill
style={{ objectFit: "cover" }}
quality={100}
className={!file.uploaded ? "opacity-50" : ""}
/>
{file.uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(idx)}>
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
) : (
<Loader />
)}
</div>
) : (
<div className="relative flex h-24 w-40 flex-col items-center justify-center rounded-lg border border-slate-300 px-2 py-3">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 w-full truncate text-center text-sm text-slate-500" title={file.name}>
<span className="font-semibold">{file.name}</span>
</p>
{file.uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(idx)}>
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
) : (
<Loader />
)}
</div>
)}
</>
))}
<input
type="file"
id="modifyFile"
name="modifyFile"
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
className="hidden"
onChange={async (e) => {
const file = e.target?.files?.[0];
if (file) {
await handleFileUpload({
file,
allowedFileExtensions,
environmentId,
});
}
}}
<Uploader
id={id}
name="uploadMore"
handleDragOver={handleDragOver}
uploaderClassName="h-24 w-40"
handleDrop={handleUploadMoreDrop}
allowedFileExtensions={allowedFileExtensions}
multiple={multiple}
handleUpload={handleUploadMore}
uploadMore={true}
/>
</div>
) : (
<div className="h-52">
{isImage(selectedFiles[0].name) ? (
<div className="relative mx-auto h-full w-full overflow-hidden rounded-lg">
<Image
src={selectedFiles[0].url}
alt={selectedFiles[0].name}
fill
style={{ objectFit: "cover" }}
quality={100}
className={!selectedFiles[0].uploaded ? "opacity-50" : ""}
/>
</label>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 bg-opacity-50 hover:bg-slate-200/50">
<TrashIcon
className="h-5 text-slate-700 hover:text-slate-900"
onClick={() => onFileUpload(undefined)}
/>
</div>
{selectedFiles[0].uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(0)}>
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
) : (
<Loader />
)}
</div>
) : (
<div className="relative flex h-full w-full flex-col items-center justify-center border border-slate-300">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500">
<span className="font-semibold">{selectedFiles[0].name}</span>
</p>
{selectedFiles[0].uploaded ? (
<div
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
onClick={() => handleRemove(0)}>
<XMarkIcon className="h-5 text-slate-700 hover:text-slate-900" />
</div>
) : (
<Loader />
)}
</div>
)}
</div>
{fileUrl.endsWith("jpg") || fileUrl.endsWith("jpeg") || fileUrl.endsWith("png") ? (
<img
src={fileUrl}
alt="Company Logo"
className="max-h-full max-w-full rounded-lg object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500">
<span className="font-semibold">{fileUrl.split("/").pop()}</span>
</p>
</div>
)}
</>
) : !isUploaded && selectedFile ? (
<>
{selectedFile.type.startsWith("image/") ? (
<img
src={URL.createObjectURL(selectedFile)}
alt="Company Logo"
className="max-h-full max-w-full rounded-lg object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500">
<span className="font-semibold">{selectedFile.name}</span>
</p>
</div>
)}
<div className="hover.bg-opacity-60 absolute inset-0 flex items-center justify-center bg-black bg-opacity-20 transition-opacity duration-300">
<label htmlFor="selectedFile" className="cursor-pointer text-sm font-semibold text-white">
Uploading
</label>
</div>
</>
)
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
{!isError && <ArrowUpTrayIcon className="h-6 text-slate-500" />}
<p className={cn("mt-2 text-sm text-slate-500", isError && "text-red-500")}>
<span className="font-semibold">
{isError ? "Failed to upload file! Please try again." : "Click or drag to upload files."}
</span>
</p>
{isError && (
<Button
variant="warn"
className="mt-2"
onClick={() => {
setIsError(false);
setIsUploaded(false);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.click();
}
}}
type="button">
Retry
</Button>
)}
<input
type="file"
id="selectedFile"
ref={fileInputRef}
name="selectedFile"
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
className="hidden"
onChange={async (e) => {
const file = e.target?.files?.[0];
if (file) {
await handleFileUpload({
file,
allowedFileExtensions,
environmentId,
});
}
}}
/>
</div>
<Uploader
id={id}
name="selected-file"
handleDragOver={handleDragOver}
handleDrop={handleDrop}
uploaderClassName="h-52 w-full"
allowedFileExtensions={allowedFileExtensions}
multiple={multiple}
handleUpload={handleUpload}
/>
)}
</label>
</div>
);
};
export default FileInput;
const Uploader = ({
id,
name,
handleDragOver,
uploaderClassName,
handleDrop,
allowedFileExtensions,
multiple,
handleUpload,
uploadMore = false,
}: {
id: string;
name: string;
handleDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
uploaderClassName: string;
handleDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
allowedFileExtensions: TAllowedFileExtensions[];
multiple: boolean;
handleUpload: (files: File[]) => void;
uploadMore?: boolean;
}) => {
return (
<label
htmlFor={`${id}-${name}`}
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800",
uploaderClassName
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpTrayIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}>
<span className="font-semibold">Click or drag to upload files.</span>
</p>
<input
type="file"
id={`${id}-${name}`}
name={`${id}-${name}`}
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
className="hidden"
multiple={multiple}
onChange={async (e) => {
let selectedFiles = Array.from(e.target?.files || []);
handleUpload(selectedFiles);
}}
/>
</div>
</label>
);
};
const Loader = () => {
return (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="h-7 w-7 animate-spin text-slate-900" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
};

View File

@@ -0,0 +1,32 @@
"use client";
import Image from "next/image";
interface PictureSelectionResponseProps {
choices: { id: string; imageUrl: string }[];
selected: string | number | string[];
}
export const PictureSelectionResponse = ({ choices, selected }: PictureSelectionResponseProps) => {
if (typeof selected !== "object") return null;
const choiceImageMapping = choices.reduce((acc, choice) => {
acc[choice.id] = choice.imageUrl;
return acc;
}, {} as Record<string, string>);
return (
<div className="my-1 flex flex-wrap gap-x-5 gap-y-4">
{selected.map((id) => (
<div className="relative h-32 w-56">
<Image
src={choiceImageMapping[id]}
alt={choiceImageMapping[id].split("/").pop() || "Image"}
fill
style={{ objectFit: "cover" }}
className="rounded-lg"
/>
</div>
))}
</div>
);
};

View File

@@ -24,6 +24,7 @@ import QuestionSkip from "./components/QuestionSkip";
import ResponseNotes from "./components/ResponseNote";
import ResponseTagsWrapper from "./components/ResponseTagsWrapper";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { PictureSelectionResponse } from "../PictureSelectionResponse";
export interface SingleResponseCardProps {
survey: TSurvey;
@@ -306,6 +307,11 @@ export default function SingleResponseCard({
{response.data[question.id]}
</p>
)
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PictureSelectionResponse
choices={question.choices}
selected={response.data[question.id]}
/>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">
{handleArray(response.data[question.id])}

View File

@@ -0,0 +1 @@
/** @type {import('tailwindcss').Config} */

2455
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff