resolved merge conflicts

This commit is contained in:
Piyush Gupta
2023-10-03 21:06:38 +05:30
138 changed files with 2001 additions and 879 deletions

View File

@@ -4,42 +4,45 @@ title: "[FEATURE]"
labels: enhancement
assignees: []
body:
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: formbricks-info
attributes:
value: |
### How we code at Formbricks 🤓
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: formbricks-info
attributes:
value: |
### How we code at Formbricks 🤓
- Everything is type-safe
- All UI components are in the package `formbricks/ui`
- Run `pnpm dev` to find a demo app to test in-app surveys at `localhost:3002`
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
context before you write your prompt.
- Follow Best Practices lined out in our [Contributor Docs](https://formbricks.com/docs/contributing/how-we-code)
- First time: Please read our [introductory blog post](https://formbricks.com/blog/join-the-formtribe)
- All UI components are in the package `formbricks/ui`
- Run `pnpm go` to find a demo app to test in-app surveys at `localhost:3002`
- Everything is type-safe
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
context before you write your prompt.
- Anything unclear? [Ask in Discord](https://formbricks.com/discord)

View File

@@ -31,9 +31,10 @@ Fixes # (issue)
<!-- We're starting to get more and more contributions. Please help us making this efficient for all of us and go through this checklist. Please tick off what you did -->
- [ ] Added a screen recording or screenshots to this PR
### Required
- [ ] Filled out the "How to test" section in this PR
- [ ] Read the [contributing guide](https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md)
- [ ] Read [How we Code at Formbricks](<[https://github.com/formbricks/formbricks/blob/main/CONTRIBUTING.md](https://formbricks.com/docs/contributing/how-we-code)>)
- [ ] Self-reviewed my own code
- [ ] Commented on my code in hard-to-understand bits
- [ ] Ran `pnpm build`
@@ -41,4 +42,8 @@ Fixes # (issue)
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
### Appreciated
- [ ] If a UI change was made: Added a screen recording or screenshots to this PR
- [ ] Updated the Formbricks Docs if changes were necessary

View File

@@ -0,0 +1,15 @@
import Image from "next/image";
export const meta = {
title: "Gitpod Setup",
description:
"With one click, you can setup the Formbricks developer environment in your browser using Gitpod",
};
### One Click Setup
1. Click the button below to open this project in Gitpod.
2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed.
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks)

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,128 @@
import Image from "next/image";
import CorsHandling from "./cors-handling-in-api.webp";
export const meta = {
title: "Formbricks Code Contribution Guide: Best Practices and Standards",
description:
"Effortlessly Navigate Your Contribution Journey with Formbricks' Coding Guidelines and PR Review Process",
};
#### Contributing
# How we Code at Formbricks
Thank you for choosing to contribute to Formbricks. Before you start, please familiarize yourself with our coding standards and best practices, as these are key criteria for pull request reviews. Your contributions are greatly valued, and if you have any questions about these guidelines, please don't hesitate to ask.
**Table of content**
- Use Typescript types throughout the application
- Always prioritise Server components
- Fetch data only in server components
- Use Server-Action for mutations
- Use service abstraction instead of direct database calls
- Handle authentication and CORS in management APIs
- Always Document API changes
- Constants should be in the packages folder
- Types should be in the packages folder
- How we handle Pull Requests
- Read environment variables from `.env.mjs`
---
## Use Typescript types throughout the application
The entire codebase is written in TypeScript, and it is crucial that every new piece of code is thoroughly and accurately typed. Instead of resorting to using the `any` type for variables, please ensure that you explicitly specify the appropriate type.
## Always prioritise Server components
When it comes to prioritizing the development of our components, our main focus is on building them as server components. This ensures that they are optimized for server-side rendering and can handle any necessary interactivity seamlessly. However, in cases where a component requires client-side interactivity, we are able to adapt it into a client component. If you would like to learn more about the advantages and benefits of server components, we highly recommend reading the comprehensive documentation provided by Next.js, which can be accessed [here](https://nextjs.org/docs/app/building-your-application/rendering/server-components).
## Fetch data only in server components
In order to ensure that both data fetching and rendering take place on the server, it is expected that actions to fetch data from the server will be performed within server components. This approach is prioritized as discussed in the previous point, which provides further details on the benefits and importance of server components.
**Note**: Data fetching is done in the `page.tsx` of the route or the corresponding server component that needs this data.
## Use Server-Action for mutations
Server actions are used to perform server actions in client components. For example, a button click (client-side) that should change something in the database. Server actions should be placed in an `actions.ts` file within the specific route to maintain code organization and facilitate efficient development.
## Use service abstraction instead of direct database calls
We utilize [prisma](https://www.prisma.io/) as our Object-Relational Mapping (ORM) tool to interact with the database. This implies that when you need to fetch or modify data in the database, you will be utilizing prisma. All prisma calls should be written in the services folder `packages/lib/services`, and before creating a new service, please ensure that one does not already exist.
## Handle authentication and CORS in management APIs
We have two APIs: Management API and Client API.
The public endpoints of the Client API are used by the link survey and `formbricks-js` to send responses and displays to formbricks. Client APIs can be found in `apps/web/app/api/v1/client`
The Management API offers the same functionality as the management frontend and can be used to create surveys, view responses or change account settings. The endpoints require an api key that the user can obtain in the management frontend. Management APIs can be found in `apps/web/app/api/v1/management`.
Please keep the following in mind:
- When dealing with Management APIs always make sure to require authentication via API keys and a sufficient authorization check.
- Make sure to handle CORS requests in any new Client API endpoint you create as these are called from the browser in link surveys or `formbricks-js`. Example below:
<Image
src={CorsHandling}
alt="Cors handling within an API"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Always Document API changes
It is imperative that any and all modifications or updates made to the client API are thoroughly and comprehensively documented. This documentation should provide clear and detailed information about the nature of the changes, their impact on existing functionality, and any new features or improvements introduced. This practice not only ensures transparency and accountability but also aids developers, both internal and external, in understanding and effectively utilizing the API, ultimately fostering a more robust and user-friendly development ecosystem.
## Constants should be in the packages folder
You should store constants in `packages/lib/constants`
## Types should be in the packages folder
You should store type in `packages/types/v1`
## Read environment variables from `.env.mjs`
Environment variables (`process.env`) shouldnt be accessed directly but be added in the `.env.mjs` and should be accessed from here. This practice helps us ensure that the variables are typesafe.
## How we handle Pull Requests
We have a number of requirements for PRs to ensure they are as easy to review as possible and to ensure that they are up to standard with the code.
### Code change
When submitting a pull request, it is important to avoid combining multiple changes or issues into a single PR. By keeping each PR focused on a specific change or issue, it becomes easier and faster to review. Additionally, separating changes into individual PRs makes it easier to test each change independently. This allows for more efficient and thorough testing, ensuring that each change is properly validated before merging.
### Title & Content
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). You should provide a short and concise title. Dont put something generic (e.g. bug fixes), and instead mention more specifically what your PR achieves, for instance “fix: dropdown not expanding on survey page”.
For the PR description, you should go into much greater detail about what your PR adds or fixes. Firstly, the description should include a link to any relevant issues or discussions surrounding the feature or bug that your PR addresses.
### Feature PRs
Give a functional overview of how your feature works, including how the user can use the feature. Then, share any technical details in an overview of how the PR works (e.g. “Once the user enters their password, the password is hashed using BCrypt and stored in the Users database field”).
### Bug Fix PRs
Give an overview of how your PR fixes the bug both as a high-level overview and a technical explanation of what caused the issue and how your PR resolves this.
Add a short video or screenshots of what your PR achieves. Loom is a great way of sharing short videos however you can upload your videos directly to the PR description.
### Code Quality & Styling
All submitted code must match our **[code styling](https://www.notion.so/Code-Styling-65ddc5dd2deb4b28a9876f1f7cc89ca9?pvs=21)** standards. We will reject pull requests that differ significantly from our standardised code styles.
All code is automatically checked by Github actions, and will notify you if there are any issues with the code that you submit. We require that code passes these quality checks before merging.
### PR review process
At the moment Matti is responsible for approving and merging pull requests, so please make sure to request his review when opening the PR and make the changes he requests in order to merge the PR.
### Making a Pull Request
- Be sure to **[check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork)** while creating your PR.
- If your PR refers to or fixes an issue, be sure to add **`refs #XXX`** or **`fixes #XXX`** to the PR description. Replacing **`XXX`** with the respective issue number. See more about **[Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)** .
- Be sure to fill the PR Template accordingly.

View File

@@ -1,6 +1,7 @@
export const meta = {
title: "Formbricks Open Source Contribution Guide: How to Enhance yourself and Contribute to Formbricks",
description: "Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
description:
"Join the Formbricks community and learn how to effectively contribute. From raising issues and feature requests to creating PRs, discover the best practices and communicate with our responsive team on Discord",
};
#### Contributing
@@ -36,3 +37,11 @@ We are currently working on having a clear [Roadmap](https://github.com/formbric
But you can also pick a feature that is not already on the roadmap if you think it creates a positive impact for Formbricks.
If you are at all unsure, just raise it as an enhancement issue first and tell us that you like to work on it, and we'll very quickly respond.
## Contributor License Agreement (CLA)
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
Please note that we can only get your contribution merged when we have a CLA signed by you.
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)

View File

@@ -1,5 +1,5 @@
import { GettingStarted } from "@/components/docs/GettingStarted";
import { BestPractices } from "@/components/docs/BestPractices";
import BestPractices from "@/components/docs/BestPractices";
import { HeroPattern } from "@/components/docs/HeroPattern";
import { Button } from "@/components/docs/Button";

View File

@@ -151,7 +151,7 @@ function BestPractice({ resource }: { resource: BestPractice }) {
);
}
export function BestPractices() {
export default function BestPractices() {
return (
<div className="my-16 xl:max-w-none">
<Heading level={2} id="resources">

View File

@@ -1,16 +1,16 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { AnimatePresence, motion, useIsPresent } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRef } from "react";
import { remToPx } from "@/lib/remToPx";
import { Button } from "./Button";
import { useIsInsideMobileNavigation } from "./MobileNavigation";
import { useSectionStore } from "./SectionProvider";
import { Tag } from "./Tag";
import { remToPx } from "@/lib/remToPx";
interface NavGroup {
title: string;
@@ -253,7 +253,9 @@ export const navigation: Array<NavGroup> = [
title: "Contributing",
links: [
{ title: "Introduction", href: "/docs/contributing/introduction" },
{ title: "How we code at Formbricks", href: "/docs/contributing/how-we-code" },
{ title: "Setup Dev Environment", href: "/docs/contributing/setup" },
{ title: "Gitpod", href: "/docs/contributing/gitpod" },
{ title: "Demo App", href: "/docs/contributing/demo" },
{ title: "Troubleshooting", href: "/docs/contributing/troubleshooting" },
],

View File

@@ -1,5 +1,4 @@
import GitHubMarkWhite from "@/images/github-mark-white.svg";
import GitHubMarkDark from "@/images/github-mark.svg";
import HackIconGold from "@/images/formtribe/hack-icon-gold.svg";
import Image from "next/image";
import Link from "next/link";
@@ -10,7 +9,7 @@ export const GitHubSponsorship: React.FC = () => {
@media (min-width: 426px);
`}</style>
<div className="right-24 lg:absolute">
<Image
{/* <Image
src={GitHubMarkDark}
alt="GitHub Sponsors Formbricks badge"
width={100}
@@ -22,19 +21,24 @@ export const GitHubSponsorship: React.FC = () => {
alt="GitHub Sponsors Formbricks badge"
width={100}
height={100}
className="mr-12 hidden dark:block md:mr-4 "
className="mr-12 hidden dark:block md:mr-4"
/> */}
<Image
src={HackIconGold}
alt="Hacktober Icon Gold"
width={100}
height={100}
className="mr-12 md:mr-4"
/>
</div>
<h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-800 dark:text-slate-200 lg:text-2xl">
Proudly Open-Source 🤍
The FormTribe goes Hacktoberfest 🥨
</h2>
<p className="lg:text-md mt-4 max-w-3xl text-slate-500 dark:text-slate-400">
We&apos;re proud to to be supported by GitHubs Open-Source Program!{" "}
Write code, win a Mac! We&apos;re running a Hacktoberfest community Hackathon:
<span>
<Link
href="/blog/inaugural-batch-github-accelerator"
className="decoration-brand-dark underline underline-offset-4">
Read more.
<Link href="/formtribe" className="decoration-brand-dark ml-2 underline underline-offset-4">
Find out more.
</Link>
</span>
</p>

View File

@@ -20,10 +20,10 @@ export const Hero: React.FC = ({}) => {
<div className="relative">
<div className="px-4 pb-20 pt-16 text-center sm:px-6 lg:px-8 lg:pb-32 lg:pt-20">
<a
href="https://github.com/formbricks/formbricks"
href="https://formbricks.com/formtribe"
target="_blank"
className="border-brand-dark rounded-full border px-4 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
We&apos;re Open-Source | Star us on GitHub{" "}
className="border-brand-dark animate-bounce rounded-full border px-4 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
The FormTribe Hackathon is on 🔥
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
</a>
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">

View File

@@ -3,26 +3,24 @@ import { Button } from "@formbricks/ui";
export const OpenSourceInfo = () => {
return (
<div className="my-8 md:my-20">
<div className="px-4 md:px-16">
<div className=" rounded-xl bg-slate-100 px-4 py-4 dark:bg-slate-800 md:px-12">
<div className="px-8 md:px-16">
<div className=" rounded-xl bg-slate-100 px-4 py-8 dark:bg-slate-800 md:px-12">
<h2 className="text-lg font-semibold leading-7 tracking-tight text-slate-800 dark:text-slate-200 md:text-2xl">
Open Source
</h2>
<p className=" text-slate-800 dark:text-slate-200">
<p className=" my-2 text-slate-600 dark:text-slate-300">
Formbricks is an open source project. You can self-host it for free. We provide multiple easy
deployment options as per your customisation needs. We have documented the process of self-hosting
Formbricks on your own server using Docker, Bash Scripting, and Building from Source.
</p>
<div className="flex items-center justify-center">
<div className="mt-4 space-x-2">
<Button
className="mr-4 mt-4 justify-center px-8 text-xs shadow-sm md:text-lg"
variant="highlight"
variant="darkCTA"
onClick={() => window.open("https://github.com/formbricks/formbricks", "_blank")}>
Star us on GitHub
</Button>
<Button
className="ml-4 mt-4 justify-center px-8 text-xs shadow-sm md:text-lg"
onClick={() => window.open("/docs/self-hosting/deployment", "_blank")}
variant="secondary">
Read our Docs on Self Hosting

View File

@@ -6,17 +6,17 @@ export const GetStartedWithPricing = ({ showDetailed }: { showDetailed: boolean
<div className="flex items-center gap-x-4 px-4 pb-4 md:gap-4 md:px-16">
<div className="w-1/3"></div>
<div className="w-1/3 text-left text-sm text-slate-800 dark:text-slate-100">
<p className="font-semibold">Free</p>
<p className="text-base font-semibold">Free</p>
{showDetailed && (
<p className="text-xs md:text-base">
<p className="leading text-xs text-slate-500 dark:text-slate-400 md:text-base">
General free usage on every product. Best for early stage startups and hobbyists
</p>
)}
<Button
className="mt-4 w-full justify-center bg-slate-300 px-4 text-xs shadow-sm hover:bg-slate-200 dark:bg-slate-600 dark:hover:bg-slate-500 md:text-lg"
variant={"secondary"}
className="mt-4 w-full justify-center"
variant="secondary"
onClick={() => {
window.open("https://app.formbricks.com/", "_blank");
}}>
@@ -24,16 +24,16 @@ export const GetStartedWithPricing = ({ showDetailed }: { showDetailed: boolean
</Button>
</div>
<div className="w-1/3 text-left text-sm text-slate-800 dark:text-slate-100">
<p className="font-semibold"> Paid</p>
<p className="text-base font-semibold"> Paid</p>
{showDetailed && (
<p className="text-xs md:text-base">
<p className="leading text-xs text-slate-500 dark:text-slate-400 md:text-base">
Formbricks with the next-generation features, Pay only for the tracked users.
</p>
)}
<Button
className="mt-4 w-full justify-center px-4 text-xs shadow-sm md:text-lg"
variant={"highlight"}
className="mt-4 w-full justify-center"
variant="highlight"
onClick={() => {
window.open("https://app.formbricks.com/", "_blank");
}}>

View File

@@ -1,9 +1,9 @@
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@formbricks/ui";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
export const PricingTable = ({ leadRow, pricing, endRow }) => {
return (
<div className="-mt-16 grid grid-cols-1 px-4 md:gap-4 md:px-16 ">
<div className="grid grid-cols-1 px-4 md:gap-4 md:px-16 ">
<div className="rounded-xl px-4 md:px-12">
<div className="flex items-center gap-x-4">
<div className="w-1/3 text-left font-semibold text-slate-700 dark:text-slate-200 md:text-xl">
@@ -11,7 +11,7 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
</div>
<div
className="flex w-1/3 items-center justify-center text-center text-sm font-semibold
text-slate-700 dark:text-slate-200 md:text-lg">
text-slate-500 dark:text-slate-200 md:text-lg">
{leadRow.free}
</div>
@@ -34,7 +34,7 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
</div>
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
{feature.addOnText ? (
<TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<u>{feature.free}</u>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -24,14 +24,14 @@
"@mapbox/rehype-prism": "^0.8.0",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "^2.3.0",
"@next/mdx": "13.5.3",
"@next/mdx": "13.4.19",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "20.7.0",
"@types/react-highlight-words": "^0.16.5",
"@types/node": "20.6.0",
"@types/react-highlight-words": "^0.16.4",
"acorn": "^8.10.0",
"autoprefixer": "^10.4.16",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"fast-glob": "^3.3.1",
"flexsearch": "^0.7.31",
@@ -39,13 +39,13 @@
"lottie-web": "^5.12.2",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.3",
"next": "13.5.3",
"next": "13.4.19",
"next-plausible": "^3.11.1",
"next-seo": "^6.1.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.2.1",
"node-fetch": "^3.3.2",
"prism-react-renderer": "^2.1.0",
"prism-react-renderer": "^2.0.6",
"prismjs": "^1.29.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.6",
@@ -58,7 +58,7 @@
"remark": "^14.0.3",
"remark-gfm": "^3.0.1",
"remark-mdx": "^2.3.0",
"sharp": "^0.32.6",
"sharp": "^0.32.5",
"shiki": "^0.14.4",
"simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.3.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

View File

@@ -0,0 +1,107 @@
import Image from "next/image";
import LayoutMdx from "@/components/shared/LayoutMdx";
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
import GitpodImage from "./setup-formbricks-via-gitpod.png";
import PackagesFolderImage from "./formbricks-packages-folder.png";
import AuthorBox from "@/components/shared/AuthorBox";
export const meta = {
title: "Join the FormTribe 🔥",
description: "Here is everything you need to know about joining the Formbricks community",
date: "2023-10-01",
publishedTime: "2023-10-01T00:00:00",
authors: ["Johannes"],
section: "Open-Source",
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" />
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
_Get a quick intro to the Formbricks community, also known as FormTribe, and learn all the deets about making awesome contributions to the project._
## Welcome to the Formbricks community!
We are so excited to have you with us 😊
In this post we will be helping you get familiar with the Formbricks codebase and get you up to speed contributing in no time. If you want to learn about Formbricks check out our [docs intro](https://formbricks.com/docs/introduction/what-is-formbricks), for more info about our founding story and why we're building open source checkout out our [blog](https://formbricks.com/blog).
### Prerequisites
Our codebase is written fully in Typescript and we love it 😍. To power our the experience management solution, here is the stack behind it all:
[Next.js](https://nextjs.org/) - React Framework
[Prisma](https://www.prisma.io/) - ORM
[Typescript](https://www.typescriptlang.org/) - Language
[Lucide React](https://lucide.dev/guide/packages/lucide-react) - Icons
[TalwindCSS](https://tailwindcss.com/) - Styling
[Zod](https://zod.dev/) - Validation
[Auth.js](https://authjs.dev/) - Authentication
### 😎 Installation and Setup
To get up and running we have 2 options: Gitpod and local.
#### Get started with Gitpod
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks)
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
#### Run on a local machine
If you choose to get setup locally, we also have a well documented guide to hold you through the process, you can find it [here](https://formbricks.com/docs/contributing/setup)
### 👩🏽‍💻 Codebase Overview
Our codebase is a [monorepo](https://en.wikipedia.org/wiki/Monorepo) which means we have different projects in one repository. At moment we have 3 different projects:
1. **demo** `apps/demo` - It's a simple React app that you can run locally and use to trigger actions and set **[Attributes](https://formbricks.com/docs/attributes/why)**. It allows you to test your setup easily.
2. **formbricks-com** `apps/formbricks-com` - The landing page of [Formbricks](https://formbricks.com)
3. **web** `apps/web` - Our [cloud offering](https://app.formbricks.com/) for Formbricks.
<Image src={MonorepoImage} alt="Formbricks monorepo folder structure" className="w-full rounded-lg" />
#### TurboRepo and our own packages
To manage all of these projects in one repository we use [turborepo](https://turbo.build/repo/docs/core-concepts/monorepos). Depending on what part of the codebase you need to contribute in, now you know where to begin 😃
We also have a set of packages which we manage: They are located in the `packages` folder. There we keep our styling library, components, database migrations and connection, a couple of configurations and much more. We do this to use any of these packages seamlessly between our mono repos.
<Image src={PackagesFolderImage} alt="Formbricks packages folder" className="w-full rounded-lg" />
### ⚖️ Contribution Guidelines
You want to get started contributing? Amazing! Checkout our must-read post on [How we Code at Formbricks](https://formbricks.com/docs/contributing/how-we-code). This will give you everything you need to know about successfully contributing to our codebase in no time.
### 🤗 Our Community
We really value our community. It might be small but it is close to our hearts. Join our [Discord](https://formbricks.com/discord) to learn from other contributors and meet the Formbricks community.
### Conclusion
Contributing to open source projects like Formbricks can be a rewarding experience. By contributing, you have the opportunity to make a meaningful impact on a project used by many and gain valuable experience in the process.
Whether you are a seasoned developer or just starting out, your contributions are appreciated. We might not always have to onboard everyone but try our best. You can help improve the codebase, fix bugs, add new features, or even contribute to the documentation. Every contribution, no matter how small, can make a difference.
Not only will you be able to showcase your skills and build your portfolio, but you will also have the chance to collaborate with other talented designers and developers in the Formbricks community. You can learn from their expertise and share your own knowledge.
### So, why wait?
Join the Formbricks community today and start contributing to an up and coming open source project. Your contributions can help shape the future of Formbricks and make a positive impact on the lives of tens of thousands of users worldwide.
We look forward to seeing your contributions and welcoming you to the Formbricks community!
### [Say Hi 👋](https://formbricks.com/discord)
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

View File

@@ -16,65 +16,117 @@ import { useEffect } from "react";
const HowTo = [
{
step: "1",
header: "Pick an issue from the list below (or start with a side quest)",
header: "Pick a 'FormTribe 🔥' issue in our repository and comment.",
link: "https://formbricks.com/github",
},
{
step: "2",
header: "Comment on the issue to signal that you started working on it.",
header: "Be the first to comment and get the issue assigned.",
},
{
step: "3",
header: "Join our Discord to ask questions and submit side quests.",
link: "https://formbricks.com/discord",
header: "You now have 24h to open a draft PR ⏲️",
},
{
step: "4",
header: "Code and open a PR with your contribution. ",
header: "If your PR looks promising, we'll work with you to get it merged.",
},
{
step: "5",
header: "Get your PR merged and collect points.",
header: "For every merged PR you collect points",
},
{
step: "6",
header: "Tweet about your contribution and tag @formbricks",
header: "Solve side quests to increase your chances on the MacBook 👀",
link: "#prizes",
},
{
step: "7",
header: "Solve side quests to increase your chances on the MacBook 👀",
link: "#prizes",
header: "Join our Discord to ask questions (and submit side quests).",
link: "https://formbricks.com/discord",
},
];
const SideQuests = [
{
points: "100 Points:",
quest: "You think you're smart removing the blur to see the side quests first?",
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: "150 Points:",
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:
"You are! Take a screenshot of this and share it in the 'side-quest' channel on Discord to get 100 points.",
"Craft a meme where a brick plays a role. For extra points, tweet it, tag us and score +5 for each like.",
proof: "Share meme or link to the tweet in the “side-quest” channel.",
},
{
points: "200 Points:",
quest: "The rest of the side quests will be released on the 1st of October.",
points: "GIF Magic (100 Points)",
quest:
"Create a branded gif related to Formbricks. Upload it to Giphy. For extra points, tweet it, tag us and score +5 for each like.",
proof: "Share link to Giphy in the “side-quest” channel.",
},
{
points: "250 Points:",
quest: "Follow us on Twitter and join us on Discord to be the first to know!",
points: "Design a background (250 Points)",
quest: "Illustrate a captivating background for survey enthusiasts (more infos on Notion).",
proof: "Share the design in the “side-quest” channel.",
},
{
points: "Pushmaster Prime | +500 Points + Hoodie:",
quest: "Merge the highest amount of Formbricks PRs in October.",
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: "Guidance Guru | +500 Points + Hoodie:",
quest: "Most active and helpful in the community helping other contributors.",
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: "Buzz Builder Guru | +500 Points + Hoodie:",
quest: "Marketing Genie with great and effective ideas to spread the word about FormTribe",
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.",
proof: "Open a bug issue in our repository.",
},
{
points: "Brickify someone famous with AI (200 Points + up to 100 Points)",
quest:
"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:
"Introduce and onboard new members to the community. Have them join Discord and reply to their automated welcome message with your discord handle (in “say-hi” channel).",
proof: "New member joined and commented with your Discord handle",
},
{
points: "Feedback Fanatic (50 Points)",
quest: "Fill out our feedback survey after the hackathon with suggestions for improvement.",
proof: "Submit the survey.",
},
{
points: "Side Quest Babo (500 Points)",
quest: "Complete all side quests.",
proof: "All quests marked as completed.",
},
];
@@ -212,6 +264,115 @@ const FAQ = [
},
];
const Leaderboard = [
{
name: "Piyush",
points: "550",
link: "https://github.com/gupta-piyush19",
},
{
name: "Suman",
points: "200",
},
{
name: "Subhdeep",
points: "100",
},
{
name: "Pratik",
points: "250",
},
{
name: "Karuppiah",
points: "100",
},
{
name: "Arth",
points: "100",
},
{
name: "Neztep",
points: "100",
},
{
name: "Kelvin Parmar",
points: "200",
},
{
name: "Arjun",
points: "100",
},
{
name: "Yashhhh",
points: "200",
},
{
name: "vishleshak",
points: "100",
},
{
name: "Ashu999",
points: "100",
},
{
name: "Sachin H",
points: "100",
},
{
name: "Suraj Jadhav",
points: "100",
},
{
name: "Vishrut",
points: "250",
},
{
name: "cataxcab",
points: "100",
},
{
name: "Eldemarkki",
points: "100",
},
{
name: "Suyash",
points: "100",
},
{
name: "Rohan Gupta",
points: "100",
},
{
name: "Nafees Nazik",
points: "100",
},
{
name: "monk",
points: "100",
},
{
name: "Pratik Tiwari (Pratik)",
points: "100",
},
{
name: "Ardash Malviya",
points: "100",
},
{
name: "Aditya Deshlahre",
points: "450",
link: "https://github.com/adityadeshlahre",
},
{
name: "Rutam",
points: "250",
},
{
name: "Sagnik Sahoo",
points: "100",
},
];
export default function FormTribeHackathon() {
// dark mode fix
useEffect(() => {
@@ -234,7 +395,7 @@ export default function FormTribeHackathon() {
Write code, win a Macbook 🔥
</a>
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Let&apos;s ship Open Source Typeform in Hacktoberfest</span>
<span className="xl:inline">Let&apos;s ship Open Source Typeform during Hacktoberfest</span>
</h1>
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-6 md:text-xl">
@@ -400,6 +561,7 @@ export default function FormTribeHackathon() {
<li>🎉 1 x MacBook Air M2</li>
<li>🎉 3 x Limited FormTribe Premium Hoodie</li>
<li>🎉 10 x Limited FormTribe Premium Shirt</li>
<li>🎉 10 x 250h for Gitpod</li>
<li>🎉 50 x Sets of Formbricks Stickers</li>
</ul>
</div>
@@ -462,40 +624,57 @@ export default function FormTribeHackathon() {
</div>
))}
</div>
<div className="mt-12 flex h-64 items-center justify-center rounded-lg bg-slate-200 text-slate-600">
<div className="text-center">
<p>No issues released yet.</p>
<a
href="https://formbricks.com/discord"
target="_blank"
className="text-slate-700 underline decoration-[#013C27] underline-offset-4">
Join Discord to get notified first.
</a>
</div>
<div className="text-center">
<Button
variant="darkCTA"
href="https://github.com/formbricks/formbricks/issues"
target="_blank"
className="mx-auto mt-12 bg-gradient-to-br from-[#032E1E] via-[#032E1E] to-[#013C27] px-20 text-white ">
View FormTribe Issues on GitHub
</Button>
</div>
{/* Side Quests */}
<div className="mt-16">
<div className="mt-16" id="side-quests">
<h3 className="font-kablammo my-4 text-4xl font-bold text-slate-800">
🏰 Side Quests: Increase your chances
</h3>
<p className="w-3/4 text-slate-600">
While code contributions are what gives the most points, everyone gets to bump up their chance of
winning. Here is a list of side quests you can complete:{" "}
winning. Here is a list of side quests you can complete:
</p>
<div className="mt-8 blur">
{SideQuests.map((quest) => (
<div key={quest.points} className="mb-2 flex select-none items-center gap-x-4">
<div className="text-2xl"></div>
<div>
<p className="text-lg font-bold text-slate-700">
{quest.points} <span className="font-normal">{quest.quest}</span>
</p>
<div className="mt-8">
<TooltipProvider delayDuration={50}>
{SideQuests.map((quest) => (
<div key={quest.points}>
<Tooltip>
<TooltipTrigger>
<div className="mb-2 flex items-center gap-x-6">
<div className="text-2xl"></div>
<p className="text-left font-bold text-slate-700">
{quest.points}: <span className="font-normal">{quest.quest}</span>
</p>
</div>
</TooltipTrigger>
<TooltipContent side={"top"}>
<p className="py-2 text-center text-slate-500 dark:text-slate-400">
<p className="mt-1 text-sm text-slate-600">Proof: {quest.proof}</p>
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
))}
</TooltipProvider>
</div>
<Button
variant="darkCTA"
href="https://formbricks.notion.site/FormTribe-Side-Quests-4ab3b294cfa04e94b77dfddd66378ea2?pvs=4"
target="_blank"
className="mt-6 bg-gradient-to-br from-[#032E1E] via-[#032E1E] to-[#013C27] text-white ">
Keep track with Notion Template
</Button>
</div>
{/* The Leaderboard */}
<SectionHeading
@@ -504,13 +683,21 @@ export default function FormTribeHackathon() {
title="The Leaderboard"
description="We keep track of all contributions and side quests in Discord. Join to take part!"
/>
<div className="mt-12 flex h-64 items-center justify-center rounded-lg bg-slate-200 text-slate-600">
<div className="text-center">
<p>Not live yet.</p>
<a href="#join" className="pl-2 text-slate-700 underline decoration-[#013C27] underline-offset-4">
Sign up to get notified on kick-off.
</a>
<div className="rounded-lg border border-slate-200">
<div className=" grid grid-cols-2 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="m-2 pl-6">User</div>
<div className="m-2 pr-6 text-right">Points</div>
</div>
{Leaderboard.sort((a, b) => parseInt(b.points) - parseInt(a.points)).map((player) => (
<a href={player.link} key={player.name} className="w-full" target="_blank">
<div className="m-4 grid grid-cols-2 content-center rounded-lg hover:bg-slate-100">
<div className="flex items-center text-sm">
<div className="m-2 font-medium text-slate-900">{player.name}</div>
</div>
<div className="m-2 my-auto text-right text-sm text-slate-900">{player.points} Points</div>
</div>
</a>
))}
</div>
{/* The Timeline */}

View File

@@ -1,9 +1,9 @@
import HeroTitle from "@/components/shared/HeroTitle";
import Layout from "@/components/shared/Layout";
import { PricingTable } from "../components/shared/PricingTable";
import { PricingCalculator } from "../components/shared/PricingCalculator";
import { GetStartedWithPricing } from "@/components/shared/PricingGetStarted";
import { OpenSourceInfo } from "@/components/shared/OpenSourceInfo";
import { GetStartedWithPricing } from "@/components/shared/PricingGetStarted";
import { PricingCalculator } from "../components/shared/PricingCalculator";
import { PricingTable } from "../components/shared/PricingTable";
const inProductSurveys = {
leadRow: {
@@ -62,7 +62,7 @@ const linkSurveys = {
features: [
{ name: "Unlimited Surveys", free: true, paid: true },
{ name: "Unlimited Responses", free: true, paid: true },
{ name: "Partial Submissions", free: true, paid: true },
{ name: "Partial Responses", free: true, paid: true },
{ name: "⚙️ URL Shortener", free: true, paid: true },
{ name: "⚙️ Recall Information", free: true, paid: true },
{ name: "⚙️ Hidden Field Questions", free: true, paid: true },
@@ -110,36 +110,33 @@ const PricingPage = () => {
headingTeal="Pricing"
subheading="Choose what's best for you! All our plans start free."
/>
<div className="space-y-24">
<div>
<GetStartedWithPricing showDetailed={true} />
<GetStartedWithPricing showDetailed={true} />
<PricingTable
leadRow={inProductSurveys.leadRow}
pricing={inProductSurveys.features}
endRow={inProductSurveys.endRow}
/>
</div>
<PricingTable
leadRow={inProductSurveys.leadRow}
pricing={inProductSurveys.features}
endRow={inProductSurveys.endRow}
/>
<div className="my-12 md:my-20"></div>
<PricingTable
leadRow={linkSurveys.leadRow}
pricing={linkSurveys.features}
endRow={linkSurveys.endRow}
/>
<PricingTable
leadRow={linkSurveys.leadRow}
pricing={linkSurveys.features}
endRow={linkSurveys.endRow}
/>
<div className="my-12 md:my-20"></div>
<PricingTable
leadRow={integrations.leadRow}
pricing={integrations.features}
endRow={integrations.endRow}
/>
<div className="my-4"></div>
<GetStartedWithPricing showDetailed={false} />
<PricingCalculator />
<OpenSourceInfo />
<PricingTable
leadRow={integrations.leadRow}
pricing={integrations.features}
endRow={integrations.endRow}
/>
<div>
<PricingCalculator />
<OpenSourceInfo />
</div>
</div>
</Layout>
);
};

View File

@@ -9,11 +9,14 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { testURLmatch } from "./testURLmatch";
import { deleteActionClass, updateActionClass } from "@formbricks/lib/services/actionClass";
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
interface ActionSettingsTabProps {
environmentId: string;
@@ -77,10 +80,11 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
const filteredNoCodeConfig = filterNoCodeConfig(data.noCodeConfig as NoCodeConfig);
const updatedData: TActionClassInput = {
...data,
environmentId,
noCodeConfig: filteredNoCodeConfig,
type: "noCode",
} as TActionClassInput;
await updateActionClass(environmentId, actionClass.id, updatedData);
await updateActionClassAction(environmentId, actionClass.id, updatedData);
setOpen(false);
router.refresh();
toast.success("Action updated successfully");
@@ -94,7 +98,7 @@ export default function ActionSettingsTab({ environmentId, actionClass, setOpen
const handleDeleteAction = async () => {
try {
setIsDeletingAction(true);
await deleteActionClass(environmentId, actionClass.id);
await deleteActionClassAction(environmentId, actionClass.id);
router.refresh();
toast.success("Action deleted successfully");
setOpen(false);

View File

@@ -7,7 +7,6 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "./testURLmatch";
import { createActionClass } from "@formbricks/lib/services/actionClass";
import {
TActionClassInput,
TActionClassNoCodeConfig,
@@ -16,6 +15,7 @@ import {
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
interface AddNoCodeActionModalProps {
environmentId: string;
@@ -84,7 +84,7 @@ export default function AddNoCodeActionModal({
type: "noCode",
} as TActionClassInput;
const newActionClass: TActionClass = await createActionClass(environmentId, updatedData);
const newActionClass: TActionClass = await createActionClassAction(updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,

View File

@@ -1,27 +1,93 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createActionClass, deleteActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { canUserAccessActionClass } from "@formbricks/lib/actionClass/auth";
import { getServerSession } from "next-auth";
import { TActionClassInput } from "@formbricks/types/v1/actionClasses";
import {
getActionCountInLast24Hours,
getActionCountInLast7Days,
getActionCountInLastHour,
} from "@formbricks/lib/services/actions";
import { getSurveysByActionClassId } from "@formbricks/lib/services/survey";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function deleteActionClassAction(environmentId, actionClassId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
await deleteActionClass(environmentId, actionClassId);
}
export async function updateActionClassAction(
environmentId: string,
actionClassId: string,
updatedAction: Partial<TActionClassInput>
) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await updateActionClass(environmentId, actionClassId, updatedAction);
}
export async function createActionClassAction(action: TActionClassInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, action.environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createActionClass(action.environmentId, action);
}
export const getActionCountInLastHourAction = async (actionClassId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getActionCountInLastHour(actionClassId);
};
export const getActionCountInLast24HoursAction = async (actionClassId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getActionCountInLast24Hours(actionClassId);
};
export const getActionCountInLast7DaysAction = async (actionClassId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getActionCountInLast7Days(actionClassId);
};
export const GetActiveInactiveSurveysAction = async (
actionClassId: string
): Promise<{ activeSurveys: string[]; inactiveSurveys: string[] }> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessActionClass(session.user.id, actionClassId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const surveys = await getSurveysByActionClassId(actionClassId);
const response = {
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),

View File

@@ -4,7 +4,7 @@ import ActionClassesTable from "@/app/(app)/environments/[environmentId]/(action
import ActionClassDataRow from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionRowData";
import ActionTableHeading from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/ActionTableHeading";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { Metadata } from "next";
export const metadata: Metadata = {

View File

@@ -6,82 +6,121 @@ import n8nLogo from "@/images/n8n.png";
import MakeLogo from "@/images/make-small.png";
import { Card } from "@formbricks/ui";
import Image from "next/image";
import { getCountOfWebhooksBasedOnSource } from "@formbricks/lib/services/webhook";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getIntegrations } from "@formbricks/lib/services/integrations";
export default async function IntegrationsPage({ params }) {
const integrations = await getIntegrations(params.environmentId);
const environmentId = params.environmentId;
const [environment, integrations, userWebhooks, zapierWebhooks] = await Promise.all([
getEnvironment(environmentId),
getIntegrations(environmentId),
getCountOfWebhooksBasedOnSource(environmentId, "user"),
getCountOfWebhooksBasedOnSource(environmentId, "zapier"),
]);
const containsGoogleSheetIntegration = integrations.some(
(integration) => integration.type === "googleSheets"
);
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/getting-started/framework-guides#next-js",
docsText: "Docs",
docsNewTab: true,
label: "Javascript Widget",
description: "Integrate Formbricks into your Webapp",
icon: <Image src={JsLogo} alt="Javascript Logo" />,
connected: environment?.widgetSetupCompleted,
statusText: environment?.widgetSetupCompleted ? "Connected" : "Not Connected",
},
{
docsHref: "https://formbricks.com/docs/integrations/zapier",
docsText: "Docs",
docsNewTab: true,
connectHref: "https://zapier.com/apps/formbricks/integrations",
connectText: "Connect",
connectNewTab: true,
label: "Zapier",
description: "Integrate Formbricks with 5000+ apps via Zapier",
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
connected: zapierWebhooks > 0,
statusText:
zapierWebhooks === 1 ? "1 zap" : zapierWebhooks === 0 ? "Not Connected" : `${zapierWebhooks} zaps`,
},
{
connectHref: `/environments/${params.environmentId}/integrations/webhooks`,
connectText: "Manage Webhooks",
connectNewTab: false,
docsHref: "https://formbricks.com/docs/webhook-api/overview",
docsText: "Docs",
docsNewTab: true,
label: "Webhooks",
description: "Trigger Webhooks based on actions in your surveys",
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
connected: userWebhooks > 0,
statusText:
userWebhooks === 1 ? "1 webhook" : userWebhooks === 0 ? "Not Connected" : `${userWebhooks} zaps`,
},
{
connectHref: `/environments/${params.environmentId}/integrations/google-sheets`,
connectText: `${containsGoogleSheetIntegration ? "Manage Sheets" : "Connect"}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/integrations/google-sheets",
docsText: "Docs",
docsNewTab: true,
label: "Google Sheets",
description: "Instantly populate your spreadsheets with survey data",
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
connected: containsGoogleSheetIntegration ? true : false,
statusText: containsGoogleSheetIntegration ? "Connected" : "Not Connected",
},
{
docsHref: "https://formbricks.com/docs/integrations/n8n",
docsText: "Docs",
docsNewTab: true,
connectHref: "https://n8n.io",
connectText: "Connect",
connectNewTab: true,
label: "n8n",
description: "Integrate Formbricks with 350+ apps via n8n",
icon: <Image src={n8nLogo} alt="n8n Logo" />,
},
{
docsHref: "https://formbricks.com/docs/integrations/make",
docsText: "Docs",
docsNewTab: true,
connectHref: "https://www.make.com/en/integrations/formbricks",
connectText: "Connect",
connectNewTab: true,
label: "Make.com",
description: "Integrate Formbricks with 1000+ apps via Make",
icon: <Image src={MakeLogo} alt="Make Logo" />,
},
];
return (
<div>
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
<p className="mb-6 text-slate-500">Connect Formbricks with your favorite tools.</p>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card
docsHref="https://formbricks.com/docs/getting-started/framework-guides#next-js"
docsText="Docs"
docsNewTab={true}
label="Javascript Widget"
description="Integrate Formbricks into your Webapp"
icon={<Image src={JsLogo} alt="Javascript Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/zapier"
docsText="Docs"
docsNewTab={true}
connectHref="https://zapier.com/apps/formbricks/integrations"
connectText="Connect"
connectNewTab={true}
label="Zapier"
description="Integrate Formbricks with 5000+ apps via Zapier"
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
connectText="Manage Webhooks"
connectNewTab={false}
docsHref="https://formbricks.com/docs/webhook-api/overview"
docsText="Docs"
docsNewTab={true}
label="Webhooks"
description="Trigger Webhooks based on actions in your surveys"
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/google-sheets`}
connectText={`${containsGoogleSheetIntegration ? "Manage Sheets" : "Connect"}`}
connectNewTab={false}
docsHref="https://formbricks.com/docs/integrations/google-sheets"
docsText="Docs"
docsNewTab={true}
label="Google Sheets"
description="Instantly populate your spreadsheets with survey data"
icon={<Image src={GoogleSheetsLogo} alt="Google sheets Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/n8n"
docsText="Docs"
docsNewTab={true}
connectHref="https://n8n.io"
connectText="Connect"
connectNewTab={true}
label="n8n"
description="Integrate Formbricks with 350+ apps via n8n"
icon={<Image src={n8nLogo} alt="n8n Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/make"
docsText="Docs"
docsNewTab={true}
connectHref="https://www.make.com/en/integrations/formbricks"
connectText="Connect"
connectNewTab={true}
label="Make.com"
description="Integrate Formbricks with 1000+ apps via Make"
icon={<Image src={MakeLogo} alt="Make Logo" />}
/>
{integrationCards.map((card) => (
<Card
key={card.label}
docsHref={card.docsHref}
docsText={card.docsText}
docsNewTab={card.docsNewTab}
connectHref={card.connectHref}
connectText={card.connectText}
connectNewTab={card.connectNewTab}
label={card.label}
description={card.description}
icon={card.icon}
connected={card.connected}
statusText={card.statusText}
/>
))}
</div>
</div>
);

View File

@@ -5,17 +5,18 @@ import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import FormbricksClient from "../../FormbricksClient";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export default async function EnvironmentLayout({ children, params }) {
const session = await getServerSession(authOptions);
if (!session) {
return redirect(`/auth/login`);
}
const hasAccess = await hasUserEnvironmentAccess(session.user, params.environmentId);
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new Error("User does not have access to this environment");
throw new AuthorizationError("Not authorized");
}
return (

View File

@@ -4,7 +4,7 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@/lib/utils";
import { getPerson } from "@formbricks/lib/services/person";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSessionCount } from "@formbricks/lib/services/session";
export default async function AttributesSection({ personId }: { personId: string }) {

View File

@@ -1,5 +1,5 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";

View File

@@ -1,6 +1,6 @@
import EditApiKeys from "./EditApiKeys";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getApiKeys } from "@formbricks/lib/services/apiKey";
import { getApiKeys } from "@formbricks/lib/apiKey/service";
import { getEnvironments } from "@formbricks/lib/services/environment";
export default async function ApiKeyList({

View File

@@ -34,19 +34,29 @@ export default function EditAPIKeys({
};
const handleDeleteKey = async () => {
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
setOpenDeleteKeyModal(false);
toast.success("API Key deleted");
try {
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
toast.success("API Key deleted");
} catch (e) {
toast.error("Unable to delete API Key");
} finally {
setOpenDeleteKeyModal(false);
}
};
const handleAddAPIKey = async (data) => {
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
setOpenAddAPIKeyModal(false);
toast.success("API key created");
try {
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
toast.success("API key created");
} catch (e) {
toast.error("Unable to create API Key");
} finally {
setOpenAddAPIKeyModal(false);
}
};
return (

View File

@@ -1,11 +1,28 @@
"use server";
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteApiKey, createApiKey } from "@formbricks/lib/apiKey/service";
import { canUserAccessApiKey } from "@formbricks/lib/apiKey/auth";
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function deleteApiKeyAction(id: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessApiKey(session.user.id, id);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteApiKey(id);
}
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createApiKey(environmentId, apiKeyData);
}

View File

@@ -2,7 +2,7 @@ import { TTeam } from "@formbricks/types/v1/teams";
import React from "react";
import MembersInfo from "@/app/(app)/environments/[environmentId]/settings/members/EditMemberships/MembersInfo";
import { getMembersByTeamId } from "@formbricks/lib/services/membership";
import { getInviteesByTeamId } from "@formbricks/lib/services/invite";
import { getInvitesByTeamId } from "@formbricks/lib/services/invite";
import { TMembership } from "@formbricks/types/v1/memberships";
type EditMembershipsProps = {
@@ -18,7 +18,7 @@ export async function EditMemberships({
currentUserMembership: membership,
}: EditMembershipsProps) {
const members = await getMembersByTeamId(team.id);
const invites = await getInviteesByTeamId(team.id);
const invites = await getInvitesByTeamId(team.id);
const currentUserRole = membership?.role;
const isUserAdminOrOwner = membership?.role === "admin" || membership?.role === "owner";

View File

@@ -3,12 +3,12 @@
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/services/membership";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
export const updateProductAction = async (
environmentId: string,
@@ -34,8 +34,8 @@ export const updateProductAction = async (
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
throw new AuthorizationError("Not authorized");
}
const updatedProduct = await updateProduct(productId, data);
@@ -62,15 +62,15 @@ export const deleteProductAction = async (environmentId: string, userId: string,
throw err;
}
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
throw new AuthenticationError("You don't have access to this environment");
if (!hasUserEnvironmentAccess(session.user.id, environment.id)) {
throw new AuthorizationError("Not authorized");
}
const team = await getTeamByEnvironmentId(environmentId);
const membership = team ? await getMembershipByUserIdTeamId(userId, team.id) : null;
if (membership?.role !== "admin" && membership?.role !== "owner") {
throw new AuthenticationError("You are not allowed to delete products.");
throw new AuthorizationError("You are not allowed to delete products.");
}
const availableProducts = team ? await getProducts(team.id) : null;

View File

@@ -3,14 +3,13 @@
import DeleteDialog from "@/components/shared/DeleteDialog";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { formbricksLogout } from "@/lib/formbricks";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button, Input, ProfileAvatar } from "@formbricks/ui";
import { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { profileDeleteAction } from "./actions";
import { deleteProfileAction } from "./actions";
export function EditAvatar({ session }) {
return (
@@ -38,10 +37,9 @@ interface DeleteAccountModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
session: Session;
profile: TProfile;
}
function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) {
function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) {
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
@@ -52,7 +50,7 @@ function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountMo
const deleteAccount = async () => {
try {
setDeleting(true);
await profileDeleteAction(profile.id);
await deleteProfileAction();
await signOut();
await formbricksLogout();
} catch (error) {
@@ -105,7 +103,7 @@ function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountMo
);
}
export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) {
export function DeleteAccount({ session }: { session: Session | null }) {
const [isModalOpen, setModalOpen] = useState(false);
if (!session) {
@@ -114,7 +112,7 @@ export function DeleteAccount({ session, profile }: { session: Session | null; p
return (
<div>
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
<p className="text-sm text-slate-700">
Delete your account with all personal data. <strong>This cannot be undone!</strong>
</p>

View File

@@ -3,7 +3,7 @@
import { Button, Input, Label } from "@formbricks/ui";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { profileEditAction } from "./actions";
import { updateProfileAction } from "./actions";
import { TProfile } from "@formbricks/types/v1/profile";
export function EditName({ profile }: { profile: TProfile }) {
@@ -19,7 +19,7 @@ export function EditName({ profile }: { profile: TProfile }) {
className="w-full max-w-sm items-center"
onSubmit={handleSubmit(async (data) => {
try {
await profileEditAction(profile.id, data);
await updateProfileAction(data);
toast.success("Your name was updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);

View File

@@ -1,12 +1,21 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function profileEditAction(userId: string, data: Partial<TProfileUpdateInput>) {
return await updateProfile(userId, data);
export async function updateProfileAction(data: Partial<TProfileUpdateInput>) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await updateProfile(session.user.id, data);
}
export async function profileDeleteAction(userId: string) {
return await deleteProfile(userId);
export async function deleteProfileAction() {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await deleteProfile(session.user.id);
}

View File

@@ -28,7 +28,7 @@ export default async function ProfileSettingsPage() {
<SettingsCard
title="Delete account"
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} profile={profile} />
<DeleteAccount session={session} />
</SettingsCard>
</div>
)}

View File

@@ -1,15 +1,38 @@
"use server";
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/services/tag";
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service";
import { canUserAccessTag } from "@formbricks/lib/tag/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export const deleteTagAction = async (tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteTag(tagId);
};
export const updateTagNameAction = async (tagId: string, name: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTag(session.user.id, tagId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await updateTagName(tagId, name);
};
export const mergeTagsAction = async (originalTagId: string, newTagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorizedForOld = await canUserAccessTag(session.user.id, originalTagId);
const isAuthorizedForNew = await canUserAccessTag(session.user.id, newTagId);
if (!isAuthorizedForOld || !isAuthorizedForNew) throw new AuthorizationError("Not authorized");
return await mergeTags(originalTagId, newTagId);
};

View File

@@ -1,7 +1,7 @@
import EditTagsWrapper from "./EditTagsWrapper";
import SettingsTitle from "../SettingsTitle";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/services/tagOnResponse";
export default async function MembersSettingsPage({ params }) {

View File

@@ -19,6 +19,7 @@ interface PreviewSurveyProps {
product: TProduct;
environment: TEnvironment;
}
let surveyNameTemp;
export default function PreviewSurvey({
survey,
@@ -46,7 +47,20 @@ export default function PreviewSurvey({
}
}, [activeQuestionId, survey.type, survey, setActiveQuestionId]);
useEffect(() => {
if (survey.name !== surveyNameTemp) {
resetQuestionProgress();
surveyNameTemp = survey.name;
}
}, [survey]);
function resetQuestionProgress() {
let storePreviewMode = previewMode;
setPreviewMode("null");
setTimeout(() => {
setPreviewMode(storePreviewMode);
}, 10);
setActiveQuestionId(survey.questions[0].id);
}

View File

@@ -1,6 +1,6 @@
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurveyResponses } from "@formbricks/lib/response/service";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";

View File

@@ -1,9 +1,15 @@
"use server";
import { deleteResponse } from "@formbricks/lib/services/response";
import { deleteResponse } from "@formbricks/lib/response/service";
import { updateResponseNote, resolveResponseNote } from "@formbricks/lib/services/responseNote";
import { createTag } from "@formbricks/lib/services/tag";
import { addTagToRespone, deleteTagFromResponse } from "@formbricks/lib/services/tagOnResponse";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/services/tagOnResponse";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
import { canUserAccessResponse } from "@formbricks/lib/response/auth";
import { canUserAccessTagOnResponse } from "@formbricks/lib/tagOnResponse/auth";
export const updateResponseNoteAction = async (responseNoteId: string, text: string) => {
await updateResponseNote(responseNoteId, text);
@@ -14,17 +20,41 @@ export const resolveResponseNoteAction = async (responseNoteId: string) => {
};
export const deleteResponseAction = async (responseId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessResponse(session.user.id, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteResponse(responseId);
};
export const createTagAction = async (environmentId: string, tagName: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createTag(environmentId, tagName);
};
export const addTagToResponeAction = async (responseId: string, tagId: string) => {
export const createTagToResponeAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await addTagToRespone(responseId, tagId);
};
export const removeTagFromResponseAction = async (responseId: string, tagId: string) => {
return await deleteTagFromResponse(responseId, tagId);
export const deleteTagOnResponseAction = async (responseId: string, tagId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessTagOnResponse(session.user.id, tagId, responseId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await deleteTagOnResponse(responseId, tagId);
};

View File

@@ -9,9 +9,9 @@ import { useRouter } from "next/navigation";
import { Button } from "@formbricks/ui";
import { TTag } from "@formbricks/types/v1/tags";
import {
addTagToResponeAction,
createTagToResponeAction,
createTagAction,
removeTagFromResponseAction,
deleteTagOnResponseAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/actions";
interface ResponseTagsWrapperProps {
@@ -38,7 +38,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
const onDelete = async (tagId: string) => {
try {
await removeTagFromResponseAction(responseId, tagId);
await deleteTagOnResponseAction(responseId, tagId);
router.refresh();
} catch (e) {
@@ -89,7 +89,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
tagName: tag.name,
},
]);
addTagToResponeAction(responseId, tag.id).then(() => {
createTagToResponeAction(responseId, tag.id).then(() => {
setSearchValue("");
setOpen(false);
router.refresh();
@@ -121,7 +121,7 @@ const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
},
]);
addTagToResponeAction(responseId, tagId).then(() => {
createTagToResponeAction(responseId, tagId).then(() => {
setSearchValue("");
setOpen(false);
router.refresh();

View File

@@ -8,7 +8,7 @@ import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constant
import ResponsesLimitReachedBanner from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponsesLimitReachedBanner";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);

View File

@@ -1,8 +1,10 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyCTAQuestion } from "@formbricks/types/v1/surveys";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { questionTypes } from "@/lib/questions";
interface CTASummaryProps {
questionSummary: QuestionSummary<TSurveyCTAQuestion>;
@@ -14,6 +16,8 @@ interface ChoiceResult {
}
export default function CTASummary({ questionSummary }: CTASummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const clickedAbs = questionSummary.responses.filter((response) => response.value === "clicked").length;
const count = questionSummary.responses.length;
@@ -27,13 +31,13 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2 ">Call-to-Action</div>
<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"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses

View File

@@ -3,6 +3,8 @@ import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveyConsentQuestion } from "@formbricks/types/v1/surveys";
import { questionTypes } from "@/lib/questions";
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
interface ConsentSummaryProps {
questionSummary: QuestionSummary<TSurveyConsentQuestion>;
@@ -17,6 +19,8 @@ interface ChoiceResult {
}
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const total = questionSummary.responses.length;
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "dismissed").length;
@@ -35,13 +39,12 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2">Consent</div>
<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"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses

View File

@@ -0,0 +1,17 @@
interface HeadlineProps {
headline: string;
required?: boolean;
}
export default function Headline({ headline, required = true }: HeadlineProps) {
return (
<div className={"align-center flex justify-between gap-4 "}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{headline}</h3>
{!required && (
<span className="text-md pb-1 font-light leading-7 text-gray-500" tabIndex={-1}>
Optional
</span>
)}
</div>
);
}

View File

@@ -9,6 +9,8 @@ import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
} from "@formbricks/types/v1/surveys";
import { questionTypes } from "@/lib/questions";
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion>;
@@ -38,6 +40,8 @@ export default function MultipleChoiceSummary({
}: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === QuestionType.MultipleChoiceSingle;
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
@@ -125,16 +129,12 @@ export default function MultipleChoiceSummary({
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2">
{isSingleChoice
? "Multiple-Choice Single Select Question"
: "Multiple-Choice Multi Select Question"}
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
Multiple-Choice {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 " />

View File

@@ -1,8 +1,10 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyNPSQuestion } from "@formbricks/types/v1/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { questionTypes } from "@/lib/questions";
interface NPSSummaryProps {
questionSummary: QuestionSummary<TSurveyNPSQuestion>;
@@ -28,6 +30,8 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
return result || 0;
};
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const result: Result = useMemo(() => {
let data = {
promoters: 0,
@@ -75,13 +79,13 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2">Net Promoter Score (NPS)</div>
<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"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{result.total} responses

View File

@@ -1,3 +1,4 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import type { QuestionSummary } from "@formbricks/types/responses";
@@ -5,6 +6,7 @@ import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys";
import { PersonAvatar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { questionTypes } from "@/lib/questions";
interface OpenTextSummaryProps {
questionSummary: QuestionSummary<TSurveyOpenTextQuestion>;
@@ -16,16 +18,18 @@ function findEmail(person) {
}
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2 ">Open Text Question</div>
<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" />
{questionSummary.responses.length} Responses

View File

@@ -5,6 +5,8 @@ import { useMemo } from "react";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys";
import { RatingResponse } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/RatingResponse";
import { questionTypes } from "@/lib/questions";
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
interface RatingSummaryProps {
questionSummary: QuestionSummary<TSurveyRatingQuestion>;
@@ -17,6 +19,8 @@ interface ChoiceResult {
}
export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (questionSummary.question.type !== QuestionType.Rating) return [];
// build a dictionary of choices
@@ -77,13 +81,13 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
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">
<div>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<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="rounded-lg bg-slate-100 p-2">Rating Question</div>
<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

View File

@@ -7,7 +7,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getTagsByEnvironmentId } from "@formbricks/lib/services/tag";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getServerSession } from "next-auth";
export default async function Page({ params }) {

View File

@@ -17,7 +17,7 @@ import {
} from "@/lib/surveys/surveys";
import toast from "react-hot-toast";
import { getTodaysDateFormatted } from "@formbricks/lib/time";
import { convertToCSV } from "@/lib/csvConversion";
import { fetchFile } from "@/lib/fetchFile";
import useClickOutside from "@formbricks/lib/useClickOutside";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
@@ -131,7 +131,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
return [];
};
const csvFileName = useMemo(() => {
const downloadFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
@@ -141,12 +141,12 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
}, [survey]);
const downloadResponses = useCallback(
async (filter: FilterDownload) => {
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses;
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, downloadResponse);
const matchQandA = getMatchQandA(downloadResponse, survey);
const csvData = matchQandA.map((response) => {
const csvResponse = {
const jsonData = matchQandA.map((response) => {
const fileResponse = {
"Response ID": response.id,
Timestamp: response.createdAt,
Finished: response.finished,
@@ -166,26 +166,25 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
transformedAnswer = answer;
}
}
csvResponse[questionName] = matchingQuestion ? transformedAnswer : "";
fileResponse[questionName] = matchingQuestion ? transformedAnswer : "";
});
return csvResponse;
return fileResponse;
});
// Add attribute columns to the CSV
// Add attribute columns to the file
Object.keys(attributeMap).forEach((attributeName) => {
const attributeValues = attributeMap[attributeName];
Object.keys(attributeValues).forEach((personId) => {
const value = attributeValues[personId];
const matchingResponse = csvData.find((response) => response["Formbricks User ID"] === personId);
const matchingResponse = jsonData.find((response) => response["Formbricks User ID"] === personId);
if (matchingResponse) {
matchingResponse[attributeName] = value;
}
});
});
// Fields which will be used as column headers in the CSV
// Fields which will be used as column headers in the file
const fields = [
"Response ID",
"Timestamp",
@@ -199,23 +198,40 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
let response;
try {
response = await convertToCSV({
json: csvData,
fields,
fileName: csvFileName,
});
response = await fetchFile(
{
json: jsonData,
fields,
fileName: downloadFileName,
},
filetype
);
} catch (err) {
toast.error("Error downloading CSV");
toast.error(`Error downloading ${filetype === "csv" ? "CSV" : "Excel"}`);
return;
}
const blob = new Blob([response.csvResponse], { type: "text/csv;charset=utf-8;" });
let blob: Blob;
if (filetype === "csv") {
blob = new Blob([response.fileResponse], { type: "text/csv;charset=utf-8;" });
} else if (filetype === "xlsx") {
const binaryString = atob(response["fileResponse"]);
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
blob = new Blob([byteArray], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
} else {
throw new Error(`Unsupported filetype: ${filetype}`);
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${csvFileName}.csv`;
link.download = `${downloadFileName}.${filetype}`;
document.body.appendChild(link);
link.click();
@@ -224,7 +240,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
URL.revokeObjectURL(downloadUrl);
},
[csvFileName, responses, totalResponses, survey]
[downloadFileName, responses, totalResponses, survey]
);
const handleDateHoveredChange = (date: Date) => {
@@ -375,17 +391,31 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.ALL);
downloadResponses(FilterDownload.ALL, "csv");
}}>
<p className="text-slate-700">All responses (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER);
downloadResponses(FilterDownload.ALL, "xlsx");
}}>
<p className="text-slate-700">All responses (Excel)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">Current selection (CSV)</p>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:ring-0"
onClick={() => {
downloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">Current selection (Excel)</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,10 +1,12 @@
"use client";
import React from "react";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import type { Survey } from "@formbricks/types/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { TSurvey, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input } from "@formbricks/ui";
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { isEqual } from "lodash";
@@ -12,10 +14,7 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { validateQuestion } from "./Validation";
import { TSurvey, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { deleteSurveyAction, surveyMutateAction } from "./actions";
import { TProduct } from "@formbricks/types/v1/product";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SurveyMenuBarProps {
localSurvey: TSurveyWithAnalytics;
@@ -197,8 +196,8 @@ export default function SurveyMenuBar({
{!!localSurvey.analytics.responseRate && (
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
<p className="max-w-[90%] pl-1 text-xs lg:text-sm">
This survey received responses. To keep the data consistent, make changes with caution.
<p className=" pl-1 text-xs lg:text-sm">
This survey received responses, make changes with caution.
</p>
</div>
)}

View File

@@ -104,7 +104,14 @@ export default function WhenToSendCard({
useEffect(() => {
if (activeIndex !== null) {
setTriggerEvent(activeIndex, actionClassArray[actionClassArray.length - 1].id);
const newActionClassId = actionClassArray[actionClassArray.length - 1].id;
const currentActionClassId = localSurvey.triggers[activeIndex]?.id;
if (newActionClassId !== currentActionClassId) {
setTriggerEvent(activeIndex, newActionClassId);
}
setActiveIndex(null);
}
}, [actionClassArray, activeIndex, setTriggerEvent]);
@@ -173,45 +180,46 @@ export default function WhenToSendCard({
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
{localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClass.id}
onValueChange={(actionClassId) => setTriggerEvent(idx, actionClassId)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<button
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{actionClassArray.map((actionClass) => (
<SelectItem
value={actionClass.id}
key={actionClass.id}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mx-2 text-sm">action is performed</p>
<button onClick={() => removeTriggerEvent(idx)}>
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
</button>
{!isAddEventModalOpen &&
localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClass.id}
onValueChange={(actionClassId) => setTriggerEvent(idx, actionClassId)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<button
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{actionClassArray.map((actionClass) => (
<SelectItem
value={actionClass.id}
key={actionClass.id}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mx-2 text-sm">action is performed</p>
<button onClick={() => removeTriggerEvent(idx)}>
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
</button>
</div>
</div>
</div>
))}
))}
<div className="px-6 py-4">
<Button
variant="secondary"

View File

@@ -5,7 +5,7 @@ import SurveyEditor from "./SurveyEditor";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributeClasses } from "@formbricks/lib/services/attributeClass";
import { ErrorComponent } from "@formbricks/ui";

View File

@@ -1,12 +1,18 @@
"use server";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { updateProduct } from "@formbricks/lib/services/product";
import { updateProfile } from "@formbricks/lib/services/profile";
import { TProductUpdateInput } from "@formbricks/types/v1/product";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
import { getServerSession } from "next-auth";
import { AuthorizationError } from "@formbricks/types/v1/errors";
export async function updateProfileAction(personId: string, updatedProfile: Partial<TProfileUpdateInput>) {
return await updateProfile(personId, updatedProfile);
export async function updateProfileAction(updatedProfile: Partial<TProfileUpdateInput>) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
return await updateProfile(session.user.id, updatedProfile);
}
export async function updateProductAction(productId: string, updatedProduct: Partial<TProductUpdateInput>) {

View File

@@ -3,7 +3,6 @@
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
import { ResponseId } from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { Objective } from "@formbricks/types/templates";
import { TProfile } from "@formbricks/types/v1/profile";
@@ -14,7 +13,7 @@ import { toast } from "react-hot-toast";
type ObjectiveProps = {
next: () => void;
skip: () => void;
formbricksResponseId?: ResponseId;
formbricksResponseId?: string;
profile: TProfile;
};
@@ -43,7 +42,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
try {
setIsProfileUpdating(true);
const updatedProfile = { ...profile, objective: selectedObjective.id };
await updateProfileAction(profile.id, updatedProfile);
await updateProfileAction(updatedProfile);
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);

View File

@@ -10,7 +10,6 @@ import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
import { ResponseId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { TProduct } from "@formbricks/types/v1/product";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
@@ -25,7 +24,7 @@ interface OnboardingProps {
}
export default function Onboarding({ session, environmentId, profile, product }: OnboardingProps) {
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
const [formbricksResponseId, setFormbricksResponseId] = useState<string | undefined>();
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@@ -55,7 +54,7 @@ export default function Onboarding({ session, environmentId, profile, product }:
try {
const updatedProfile = { ...profile, onboardingCompleted: true };
await updateProfileAction(profile.id, updatedProfile);
await updateProfileAction(updatedProfile);
if (environmentId) {
router.push(`/environments/${environmentId}/surveys`);

View File

@@ -4,7 +4,6 @@ import { cn } from "@formbricks/lib/cn";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
import { ResponseId, SurveyId } from "@formbricks/js";
import { TProfile } from "@formbricks/types/v1/profile";
import { Button } from "@formbricks/ui";
import { useState } from "react";
@@ -13,7 +12,7 @@ import { toast } from "react-hot-toast";
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: ResponseId) => void;
setFormbricksResponseId: (id: string) => void;
profile: TProfile;
};
@@ -41,7 +40,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
try {
setIsUpdating(true);
const updatedProfile = { ...profile, role: selectedRole.id };
await updateProfileAction(profile.id, updatedProfile);
await updateProfileAction(updatedProfile);
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
@@ -49,7 +48,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
console.error(e);
}
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId, {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, {
role: selectedRole.label,
});
if (res.ok) {

View File

@@ -1,4 +1,4 @@
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -19,11 +19,15 @@ export async function GET(req: NextRequest) {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user, environmentId);
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
}

View File

@@ -1,7 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { AsyncParser } from "@json2csv/node";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { responses } from "@/lib/api/response";
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
let csv: string = "";
@@ -24,7 +33,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
csvResponse: csv,
fileResponse: csv,
},
{
headers,

View File

@@ -0,0 +1,39 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { responses } from "@/lib/api/response";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import * as xlsx from "xlsx";
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
const { json, fields, fileName } = data;
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(json, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
const binaryString = String.fromCharCode.apply(null, buffer);
const base64String = btoa(binaryString);
const headers = new Headers();
headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
headers.set("Content-Disposition", `attachment; filename=${fileName ?? "converted"}.xlsx`);
return NextResponse.json(
{
fileResponse: base64String,
},
{
headers,
}
);
}

View File

@@ -1,4 +1,4 @@
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { TAuthenticationApiKey } from "@formbricks/types/v1/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { responses } from "@/lib/api/response";

View File

@@ -2,7 +2,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { updateResponse } from "@formbricks/lib/services/response";
import { updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";

View File

@@ -3,7 +3,7 @@ import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/v1/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponse } from "@formbricks/lib/services/response";
import { createResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";

View File

@@ -1,6 +1,6 @@
import { getSurveysCached } from "@/app/api/v1/js/surveys";
import { MAU_LIMIT } from "@formbricks/lib/constants";
import { getActionClassesCached } from "@formbricks/lib/services/actionClass";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { createPerson, getMonthlyActivePeopleCount, getPersonCached } from "@formbricks/lib/services/person";
import { getProductByEnvironmentIdCached } from "@formbricks/lib/services/product";
@@ -101,7 +101,7 @@ export const getUpdatedState = async (
// get/create rest of the state
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveysCached(environmentId, person),
getActionClassesCached(environmentId),
getActionClasses(environmentId),
getProductByEnvironmentIdCached(environmentId),
]);

View File

@@ -1,6 +1,6 @@
import { responses } from "@/lib/api/response";
import { NextResponse } from "next/server";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/services/actionClass";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { TActionClass, ZActionClassInput } from "@formbricks/types/v1/actionClasses";
import { authenticateRequest } from "@/app/api/v1/auth";
import { transformErrorToDetails } from "@/lib/api/validator";

View File

@@ -3,7 +3,7 @@ import { DatabaseError } from "@formbricks/types/v1/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { NextResponse } from "next/server";
import { TActionClass, ZActionClassInput } from "@formbricks/types/v1/actionClasses";
import { createActionClass, getActionClasses } from "@formbricks/lib/services/actionClass";
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
import { transformErrorToDetails } from "@/lib/api/validator";
export async function GET(request: Request) {

View File

@@ -1,9 +1,9 @@
import { responses } from "@/lib/api/response";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/lib/api/validator";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/services/response";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/v1/responses";
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getSurvey } from "@formbricks/lib/services/survey";
import { authenticateRequest } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
@@ -21,7 +21,7 @@ const canUserAccessResponse = async (authentication: any, response: TResponse):
if (!survey) return false;
if (authentication.type === "session") {
return await hasUserEnvironmentAccess(authentication.session.user, survey.environmentId);
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
} else if (authentication.type === "apiKey") {
return survey.environmentId === authentication.environmentId;
} else {

View File

@@ -1,5 +1,5 @@
import { responses } from "@/lib/api/response";
import { getEnvironmentResponses } from "@formbricks/lib/services/response";
import { getEnvironmentResponses } from "@formbricks/lib/response/service";
import { authenticateRequest } from "@/app/api/v1/auth";
import { DatabaseError } from "@formbricks/types/v1/errors";

View File

@@ -1,5 +1,5 @@
import { responses } from "@/lib/api/response";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { deleteWebhook, getWebhook } from "@formbricks/lib/services/webhook";
import { headers } from "next/headers";

View File

@@ -1,7 +1,7 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/v1/errors";
import { getApiKeyFromKey } from "@formbricks/lib/services/apiKey";
import { createWebhook, getWebhooks } from "@formbricks/lib/services/webhook";
import { ZWebhookInput } from "@formbricks/types/v1/webhooks";
import { headers } from "next/headers";

View File

@@ -96,7 +96,7 @@ export default function LinkSurvey({
formbricksSignature={product.formbricksSignature}
onDisplay={async () => {
if (!isPreview) {
const { id } = await createDisplay({ surveyId: survey.id }, window?.location?.origin);
const { id } = await createDisplay({ surveyId: survey.id }, webAppUrl);
const newSurveyState = surveyState.copy();
newSurveyState.updateDisplayId(id);
setSurveyState(newSurveyState);

View File

@@ -1,5 +1,6 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { prisma } from "@formbricks/database";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createHash } from "crypto";
import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
@@ -22,7 +23,7 @@ export const hasEnvironmentAccess = async (
if (!user) {
return false;
}
const ownership = await hasUserEnvironmentAccess(user, environmentId);
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
if (!ownership) {
return false;
}
@@ -30,34 +31,6 @@ export const hasEnvironmentAccess = async (
return true;
};
export const hasUserEnvironmentAccess = async (user, environmentId) => {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
memberships: {
select: {
userId: true,
},
},
},
},
},
},
},
});
const environmentUsers = environment?.product.team.memberships.map((member) => member.userId) || [];
if (environmentUsers.includes(user.id)) {
return true;
}
return false;
};
export const getPlan = async (req, res) => {
if (req.headers["x-api-key"]) {
const apiKey = req.headers["x-api-key"].toString();

View File

@@ -1,13 +0,0 @@
export const convertToCSV = async (data: { json: any; fields?: string[]; fileName?: string }) => {
const response = await fetch("/api/csv-conversion", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to convert to CSV");
return response.json();
};

18
apps/web/lib/fetchFile.ts Executable file
View File

@@ -0,0 +1,18 @@
export const fetchFile = async (
data: { json: any; fields?: string[]; fileName?: string },
filetype: string
) => {
const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
const response = await fetch(`/api/internal/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to convert to file");
return response.json();
};

View File

@@ -1,17 +1,17 @@
import formbricks, { PersonId, SurveyId, ResponseId } from "@formbricks/js";
import formbricks from "@formbricks/js";
import { env } from "@/env.mjs";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const createResponse = async (
surveyId: SurveyId,
surveyId: string,
data: { [questionId: string]: any },
finished: boolean = false
): Promise<any> => {
const api = formbricks.getApi();
const personId = formbricks.getPerson()?.id as PersonId;
return await api.createResponse({
const personId = formbricks.getPerson()?.id;
return await api.client.response.create({
surveyId,
personId,
finished,
@@ -20,12 +20,12 @@ export const createResponse = async (
};
export const updateResponse = async (
responseId: ResponseId,
responseId: string,
data: { [questionId: string]: any },
finished: boolean = false
): Promise<any> => {
const api = formbricks.getApi();
return await api.updateResponse({
return await api.client.response.update({
responseId,
finished,
data,

View File

@@ -48,7 +48,8 @@
"react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
"swr": "^2.2.4",
"ua-parser-js": "^1.0.36"
"ua-parser-js": "^1.0.36",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",

View File

@@ -17,13 +17,12 @@
"lint": "eslint ./src --fix",
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@formbricks/lib": "workspace:*"
},
"devDependencies": {
"@formbricks/types": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"tsup": "^7.2.0"
"tsup": "^7.2.0",
"typescript": "5.1.6",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -0,0 +1,23 @@
import { Result } from "@formbricks/types/v1/errorHandlers";
import { NetworkError } from "@formbricks/types/v1/errors";
import { makeRequest } from "../../utils/makeRequest";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
export class DisplayAPI {
private apiHost: string;
constructor(baseUrl: string) {
this.apiHost = baseUrl;
}
async markDisplayedForPerson({
surveyId,
personId,
}: TDisplayInput): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(this.apiHost, "/api/v1/client/displays", "POST", { surveyId, personId });
}
async markResponded({ displayId }: { displayId: string }): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/displays/${displayId}/responded`, "POST");
}
}

View File

@@ -0,0 +1,15 @@
import { ResponseAPI } from "./response";
import { DisplayAPI } from "./display";
import { ApiConfig } from "../../types";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
constructor(options: ApiConfig) {
const { apiHost } = options;
this.response = new ResponseAPI(apiHost);
this.display = new DisplayAPI(apiHost);
}
}

View File

@@ -0,0 +1,39 @@
import { makeRequest } from "../../utils/makeRequest";
import { NetworkError } from "@formbricks/types/v1/errors";
import { Result } from "@formbricks/types/v1/errorHandlers";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
export class ResponseAPI {
private apiHost: string;
constructor(apiHost: string) {
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 update({
responseId,
finished,
data,
}: TResponseUpdateInputWithResponseId): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/responses/${responseId}`, "PUT", {
finished,
data,
});
}
}

View File

@@ -1 +0,0 @@
export * from "./responses";

View File

@@ -1,24 +0,0 @@
import { KeyValueData, PersonId, ResponseId, SurveyId } from "../types";
export interface CreateResponseResponse {
id: ResponseId;
}
export interface UpdateResponseResponse {
id: ResponseId;
createdAt: string;
updatedAt: string;
finished: boolean;
surveyId: SurveyId;
personId: PersonId;
data: KeyValueData;
meta: {}; //TODO: figure out what this is
userAttributes: string[]; //TODO: figure out what this is
tags: string[]; //TODO: figure out what this is
}
export interface UpdateResponseResponseFormatted
extends Omit<UpdateResponseResponse, "createdAt" | "updatedAt"> {
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,70 +0,0 @@
import { Result, ok } from "@formbricks/types/v1/errorHandlers";
import { ResponseCreateRequest, ResponseUpdateRequest } from "@formbricks/types/js";
import {
CreateResponseResponse,
UpdateResponseResponse,
UpdateResponseResponseFormatted,
} from "../dtos/responses";
import { NetworkError } from "../errors";
import { EnvironmentId, KeyValueData, PersonId, RequestFn, ResponseId, SurveyId } from "../types";
export interface CreateResponseOptions {
environmentId: EnvironmentId;
surveyId: SurveyId;
personId?: PersonId;
data: KeyValueData;
finished?: boolean;
}
export const createResponse = async (
request: RequestFn,
options: CreateResponseOptions
): Promise<Result<CreateResponseResponse, NetworkError>> => {
const result = await request<CreateResponseResponse, any, ResponseCreateRequest>(
`/api/v1/client/environments/${options.environmentId}/responses`,
{
surveyId: options.surveyId,
personId: options.personId,
response: {
data: options.data,
finished: options.finished || false,
},
},
{ method: "POST" }
);
return result;
};
export interface UpdateResponseOptions {
environmentId: EnvironmentId;
data: KeyValueData;
responseId: ResponseId;
finished?: boolean;
}
export const updateResponse = async (request: RequestFn, options: UpdateResponseOptions) => {
const result = await request<UpdateResponseResponse, any, ResponseUpdateRequest>(
`/api/v1/client/environments/${options.environmentId}/responses/${options.responseId}`,
{
response: {
data: options.data,
finished: options.finished || false,
},
},
{
method: "PUT",
}
);
if (result.ok === false) return result;
// convert timestamps to Dates
const newResponse: UpdateResponseResponseFormatted = {
...result.data,
createdAt: new Date(result.data.createdAt),
updatedAt: new Date(result.data.updatedAt),
};
return ok(newResponse);
};

View File

@@ -1,6 +0,0 @@
export type NetworkError = {
code: "network_error";
message: string;
status: number;
url: URL;
};

View File

@@ -1,6 +1,10 @@
export * from "./dtos/";
export * from "./errors";
export * from "./lib";
export { FormbricksAPI as default } from "./lib";
// do not export RequestFn or Brand, they are internal
export type { EnvironmentId, KeyValueData, PersonId, ResponseId, SurveyId } from "./types";
import { ApiConfig } from "./types/index";
import { Client } from "./api/client";
export class FormbricksAPI {
client: Client;
constructor(options: ApiConfig) {
this.client = new Client(options);
}
}

View File

@@ -1,85 +0,0 @@
import { Result, err, ok, wrapThrows } from "@formbricks/types/v1/errorHandlers";
import { CreateResponseResponse, UpdateResponseResponseFormatted } from "./dtos/responses";
import { NetworkError } from "./errors";
import {
CreateResponseOptions,
UpdateResponseOptions,
createResponse,
updateResponse,
} from "./endpoints/response";
import { EnvironmentId, RequestFn } from "./types";
export interface FormbricksAPIOptions {
apiHost?: string;
environmentId: EnvironmentId;
}
export class FormbricksAPI {
private readonly baseUrl: string;
private readonly environmentId: EnvironmentId;
constructor(options: FormbricksAPIOptions) {
this.baseUrl = options.apiHost || "https://app.formbricks.com";
this.environmentId = options.environmentId;
this.request = this.request.bind(this);
}
async createResponse(
options: Omit<CreateResponseOptions, "environmentId">
): Promise<Result<CreateResponseResponse, NetworkError>> {
return this.runWithEnvironmentId(createResponse, options);
}
async updateResponse(
options: Omit<UpdateResponseOptions, "environmentId">
): Promise<Result<UpdateResponseResponseFormatted, NetworkError>> {
return this.runWithEnvironmentId(updateResponse, options);
}
/*
This was added to reduce code duplication
It checks that the function passed has the environmentId in the Options type
and automatically adds it to the options
*/
private runWithEnvironmentId<T, E, Options extends { environmentId: EnvironmentId }>(
fn: (request: RequestFn, options: Options) => Promise<Result<T, E>>,
options: Omit<Options, "environmentId">
): Promise<Result<T, E>> {
const newOptions = { environmentId: this.environmentId, ...options } as Options;
return fn(this.request, newOptions);
}
private async request<T = any, E = any, Data = any>(
path: string,
data: Data,
options?: RequestInit
): Promise<Result<T, E | NetworkError | Error>> {
const url = new URL(path, this.baseUrl);
const headers: HeadersInit = {
"Content-Type": "application/json",
};
const body = JSON.stringify(data);
const res = wrapThrows(fetch)(url, { headers, body, ...options });
if (res.ok === false) return err(res.error);
const response = await res.data;
const resJson = await response.json();
if (!response.ok) {
return err({
code: "network_error",
message: response.statusText,
status: response.status,
url,
});
}
return ok(resJson as T);
}
}

View File

@@ -1,27 +0,0 @@
import { Result } from "@formbricks/types/v1/errorHandlers";
import { NetworkError } from "./errors";
// by using Brand, we can check that you can't pass to an environmentId a surveyId
type Brand<T, B> = T & { __brand: B };
export type EnvironmentId = Brand<string, "EnvironmentId">;
export type SurveyId = Brand<string, "SurveyId">;
export type PersonId = Brand<string, "PersonId">;
export type ResponseId = Brand<string, "ResponseId">;
export type KeyValueData = { [key: string]: string | number | string[] | number[] | undefined };
export type RequestFn = <T = any, E = any, Data = any>(
path: string,
data: Data,
options?: RequestInit
) => Promise<Result<T, E | NetworkError | Error>>;
// https://github.com/formbricks/formbricks/blob/fbfc80dd4ed5d768f0c549e179fd1aa10edc400a/apps/web/lib/api/response.ts
export interface ApiErrorResponse {
code: string;
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
};
}

View File

@@ -0,0 +1,8 @@
export interface ApiConfig {
environmentId: string;
apiHost: string;
}
export type ApiResponse<T> = {
data: T;
};

View File

@@ -0,0 +1,36 @@
import { Result, err, ok, wrapThrows } from "@formbricks/types/v1/errorHandlers";
import { NetworkError } from "@formbricks/types/v1/errors";
import { ApiResponse } from "../types";
export async function makeRequest<T>(
apiHost: string,
endpoint: string,
method: "GET" | "POST" | "PUT" | "DELETE",
data?: any
): Promise<Result<T, NetworkError | Error>> {
const url = new URL(endpoint, apiHost);
const body = JSON.stringify(data);
const res = wrapThrows(fetch)(url.toString(), {
method,
headers: {
"Content-Type": "application/json",
},
body,
});
if (res.ok === false) return err(res.error);
const response = await res.data;
const { data: innerData } = (await response.json()) as ApiResponse<T>;
if (!response.ok) {
return err({
code: "network_error",
message: response.statusText,
status: response.status,
url,
});
}
return ok(innerData as T);
}

View File

@@ -8,8 +8,6 @@ import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeEvents";
import { resetPerson, setPersonAttribute, setPersonUserId, getPerson, logoutPerson } from "./lib/person";
export type { EnvironmentId, KeyValueData, PersonId, ResponseId, SurveyId } from "@formbricks/api";
const logger = Logger.getInstance();
logger.debug("Create command queue");

View File

@@ -1,4 +1,4 @@
import { FormbricksAPI, EnvironmentId } from "@formbricks/api";
import { FormbricksAPI } from "@formbricks/api";
import { Config } from "./config";
export const getApi = (): FormbricksAPI => {
@@ -11,6 +11,6 @@ export const getApi = (): FormbricksAPI => {
return new FormbricksAPI({
apiHost,
environmentId: environmentId as EnvironmentId,
environmentId,
});
};

View File

@@ -0,0 +1,27 @@
import "server-only";
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getActionClass } from "./service";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
export const canUserAccessActionClass = async (userId: string, actionClassId: string): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([userId, ZId], [actionClassId, ZId]);
if (!userId) return false;
const actionClass = await getActionClass(actionClassId);
if (!actionClass) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, actionClass.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`users-${userId}-actionClasses-${actionClassId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [`actionClasses-${actionClassId}`] }
)();

View File

@@ -2,26 +2,18 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/v1/actionClasses";
import { ZId } from "@formbricks/types/v1/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { revalidateTag, unstable_cache } from "next/cache";
import { cache } from "react";
import { validateInputs } from "../utils/validate";
const halfHourInSeconds = 60 * 30;
export const getActionClassCacheTag = (name: string, environmentId: string): string =>
`environments-${environmentId}-actionClass-${name}`;
const getActionClassCacheKey = (name: string, environmentId: string): string[] => [
getActionClassCacheTag(name, environmentId),
];
const getActionClassesCacheTag = (environmentId: string): string =>
`environments-${environmentId}-actionClasses`;
const getActionClassesCacheKey = (environmentId: string): string[] => [
getActionClassesCacheTag(environmentId),
];
const select = {
id: true,
@@ -34,34 +26,30 @@ const select = {
environmentId: true,
};
export const getActionClasses = cache(async (environmentId: string): Promise<TActionClass[]> => {
validateInputs([environmentId, ZId]);
try {
let actionClasses = await prisma.eventClass.findMany({
where: {
environmentId: environmentId,
},
select,
orderBy: {
createdAt: "asc",
},
});
return actionClasses;
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
}
});
export const getActionClassesCached = (environmentId: string) =>
export const getActionClasses = (environmentId: string): Promise<TActionClass[]> =>
unstable_cache(
async () => {
return await getActionClasses(environmentId);
validateInputs([environmentId, ZId]);
try {
let actionClasses = await prisma.eventClass.findMany({
where: {
environmentId: environmentId,
},
select,
orderBy: {
createdAt: "asc",
},
});
return actionClasses;
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
}
},
getActionClassesCacheKey(environmentId),
[`environments-${environmentId}-actionClasses`],
{
tags: getActionClassesCacheKey(environmentId),
revalidate: halfHourInSeconds,
tags: [getActionClassesCacheTag(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
@@ -96,7 +84,7 @@ export const deleteActionClass = async (
if (result === null) throw new ResourceNotFoundError("Action", actionClassId);
// revalidate cache
revalidateTag(getActionClassesCacheTag(environmentId));
revalidateTag(getActionClassesCacheTag(result.environmentId));
return result;
} catch (error) {
@@ -157,8 +145,8 @@ export const updateActionClass = async (
});
// revalidate cache
revalidateTag(getActionClassCacheTag(result.name, environmentId));
revalidateTag(getActionClassesCacheTag(environmentId));
revalidateTag(getActionClassCacheTag(result.name, result.environmentId));
revalidateTag(getActionClassesCacheTag(result.environmentId));
return result;
} catch (error) {
@@ -176,9 +164,9 @@ export const getActionClassCached = async (name: string, environmentId: string)
},
});
},
getActionClassCacheKey(name, environmentId),
[`environments-${environmentId}-actionClasses-${name}`],
{
tags: getActionClassCacheKey(name, environmentId),
revalidate: halfHourInSeconds,
tags: [getActionClassesCacheTag(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();

View File

@@ -0,0 +1,26 @@
import "server-only";
import { ZId } from "@formbricks/types/v1/environment";
import { validateInputs } from "../utils/validate";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getApiKey } from "./service";
import { unstable_cache } from "next/cache";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
export const canUserAccessApiKey = async (userId: string, apiKeyId: string): Promise<boolean> =>
await unstable_cache(
async () => {
validateInputs([userId, ZId], [apiKeyId, ZId]);
const apiKeyFromServer = await getApiKey(apiKeyId);
if (!apiKeyFromServer) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, apiKeyFromServer.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
},
[`users-${userId}-apiKeys-${apiKeyId}`],
{ revalidate: SERVICES_REVALIDATION_INTERVAL, tags: [`apiKeys-${apiKeyId}`] }
)();

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