mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-05 02:58:36 -06:00
Merge branch 'main' into surveyBg
This commit is contained in:
3
.github/workflows/release-docker.yml
vendored
3
.github/workflows/release-docker.yml
vendored
@@ -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 }}
|
||||
|
||||
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function LogicEditor({
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
const logicConditions: LogicConditions = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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() !== "";
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)] }
|
||||
)();
|
||||
|
||||
34
packages/lib/apiKey/cache.ts
Normal file
34
packages/lib/apiKey/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
34
packages/lib/attributeClass/cache.ts
Normal file
34
packages/lib/attributeClass/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
14
packages/lib/attributeClass/util.ts
Normal file
14
packages/lib/attributeClass/util.ts
Normal 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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
};
|
||||
|
||||
34
packages/lib/environment/cache.ts
Normal file
34
packages/lib/environment/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
14
packages/lib/environment/util.ts
Normal file
14
packages/lib/environment/util.ts
Normal 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;
|
||||
};
|
||||
34
packages/lib/integration/cache.ts
Normal file
34
packages/lib/integration/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
26
packages/lib/invite/cache.ts
Normal file
26
packages/lib/invite/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
14
packages/lib/invite/util.ts
Normal file
14
packages/lib/invite/util.ts
Normal 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;
|
||||
};
|
||||
26
packages/lib/membership/cache.ts
Normal file
26
packages/lib/membership/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
|
||||
42
packages/lib/product/cache.ts
Normal file
42
packages/lib/product/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
26
packages/lib/profile/cache.ts
Normal file
26
packages/lib/profile/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
26
packages/lib/responseNote/cache.ts
Normal file
26
packages/lib/responseNote/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
26
packages/lib/session/cache.ts
Normal file
26
packages/lib/session/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
17
packages/lib/session/util.ts
Normal file
17
packages/lib/session/util.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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)] }
|
||||
)();
|
||||
|
||||
42
packages/lib/survey/cache.ts
Normal file
42
packages/lib/survey/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
}
|
||||
)();
|
||||
|
||||
27
packages/lib/tagOnResponse/cache.ts
Normal file
27
packages/lib/tagOnResponse/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
)();
|
||||
|
||||
34
packages/lib/team/cache.ts
Normal file
34
packages/lib/team/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
)();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)();
|
||||
|
||||
35
packages/lib/webhook/cache.ts
Normal file
35
packages/lib/webhook/cache.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
178
packages/surveys/src/components/PictureSelectionQuestion.tsx
Normal file
178
packages/surveys/src/components/PictureSelectionQuestion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
32
packages/ui/PictureSelectionResponse/index.tsx
Normal file
32
packages/ui/PictureSelectionResponse/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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])}
|
||||
|
||||
1
packages/ui/tailwind.config.js
Normal file
1
packages/ui/tailwind.config.js
Normal file
@@ -0,0 +1 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
2455
pnpm-lock.yaml
generated
2455
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user