Compare commits

..

35 Commits

Author SHA1 Message Date
Matti Nannt
8b77494e32 fix: person update endpoint not working due to caching issue (#1666) 2023-11-21 15:02:01 +00:00
Matti Nannt
9ad5d4ec5c fix: caching issue leads to action endpoint fail for new person (#1665) 2023-11-21 14:35:00 +00:00
Dhruwang Jariwala
1582ac13da fix: Weekly summary endpoint failing (#1440)
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-21 14:00:31 +00:00
Shubham Palriwala
61c7d78612 feat: new client actions endpoint docs (#1659) 2023-11-21 12:55:33 +00:00
Shubham Palriwala
556fe5453c feat: updated client display endpoint docs & update display bug fix (#1654) 2023-11-21 12:35:56 +00:00
Jonas Höbenreich
fd59ec4f8e fix: improve a11y of survey modal close button (#1661)
Co-authored-by: jonas.hoebenreich <jonas.hoebenreich@flixbus.com>
2023-11-21 12:24:51 +00:00
Matti Nannt
6db76d094b chore: move n8n-node to own repository (#1662) 2023-11-21 12:09:53 +00:00
Matti Nannt
94bf1fd6fe fix: close formbricks-js surveys on logout (#1655) 2023-11-21 09:29:31 +00:00
Rohit Mondal
860630dd5a style: fixed color contrast in onboarding page (#1631)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-21 09:28:03 +00:00
Dhruwang Jariwala
97cc6232c2 fix: page refresh issue on adding new action (#1634) 2023-11-21 08:55:02 +00:00
Matti Nannt
7331d1dd5a chore: update npm dependencies to latest version (#1651) 2023-11-21 08:32:07 +00:00
Matti Nannt
3f8bf4c34c chore: simplify getPersonByUserId by removing legacy person support (#1649) 2023-11-20 21:07:40 +00:00
Matti Nannt
91ceffba01 fix: personByUserId not cached properly (#1644) 2023-11-20 20:05:58 +00:00
Shaik_Asif
8c38495812 fix: typo in template (#1648) 2023-11-20 19:58:05 +00:00
Matti Nannt
c8c98499ed chore: Simplify person service by removing complex getOrCreatePerson function (#1643) 2023-11-20 17:22:11 +00:00
Shubham Palriwala
af181eabdc feat: formbricks/api package as per js package 1.2.2 (#1640)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 16:16:39 +00:00
Neil Chauhan
822c48ff52 fix: headline alignment issue (#1641)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 15:02:46 +00:00
Neil Chauhan
70d211a038 fix: thank you card headline issue FOR-1489 (#1639) 2023-11-20 08:25:15 +00:00
Dhruwang Jariwala
a77ce55a1d fix: Error screen on survey Editor refresh (#1635) 2023-11-19 12:47:44 +00:00
Matti Nannt
a376eb9b51 fix: caching issue by simplifying person service (#1636) 2023-11-17 19:15:17 +00:00
Matti Nannt
f11c47d4ca chore: make getSyncSurveys cached by default (#1609) 2023-11-17 18:29:08 +00:00
Dhruwang Jariwala
4baea07471 fix: wording on look & feel page (#1632) 2023-11-17 09:46:43 +00:00
Johannes
ff87be717c fix: add canonical URL for lp (#1633) 2023-11-17 09:31:25 +00:00
Matti Nannt
e3e595af9a chore: upgrade packages, fix edge runtime issues (#1629) 2023-11-16 21:45:48 +00:00
Matti Nannt
3dae10d665 feat: load react email serverside (#1628) 2023-11-16 19:58:06 +00:00
Dhruwang Jariwala
6727ccf1cd fix: response delete in single response card (#1624) 2023-11-16 12:58:32 +00:00
Shubham Palriwala
9242ab3a7d feat: rate limit client API endpoints (#1566) 2023-11-16 12:57:19 +00:00
Matti Nannt
e9d8de3574 fix: zapier integration not able to pull past responses (#1627) 2023-11-16 12:07:30 +00:00
Dhruwang Jariwala
0a252e5827 fix: hidden field not working with prefilling on 1st question (#1602)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-16 09:20:04 +00:00
Dhruwang Jariwala
632f6068c4 feat: test template (#1620) 2023-11-16 09:09:46 +00:00
Dhruwang Jariwala
4d280e04d1 fix: logic jumps (#1623) 2023-11-16 08:45:51 +00:00
Krishanu Saha
73bde4fda6 fix: #1357 button text color changed on nearwhite background color (#1365)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-15 15:47:59 +00:00
Midka
9d4e21f8a7 feat(packages/surveys): ability to customize colors & other improvements (#916)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Neil Chauhan <neilchauhan2@gmail.com>
2023-11-15 15:29:27 +00:00
Dhruwang Jariwala
3eeea7d1b2 fix: survey editor issue (#1619) 2023-11-15 13:03:28 +00:00
Dhruwang Jariwala
32268a8ec3 fix: auto focus issue (#1618) 2023-11-15 11:35:01 +00:00
160 changed files with 4843 additions and 7240 deletions

View File

@@ -16,10 +16,8 @@ env:
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
@@ -38,7 +36,7 @@ jobs:
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with:
cosign-release: 'v2.1.1'
cosign-release: "v2.1.1"
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
@@ -71,11 +69,16 @@ jobs:
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: ./apps/web/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
DATABASE_URL=${{ env.DATABASE_URL }}
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker

View File

@@ -1,152 +0,0 @@
import formbricks from "@formbricks/js";
import { useRouter } from "next/router";
import { FormEvent } from "react";
export default function SiginPage() {
const router = useRouter();
const submitAction = (e: FormEvent) => {
e.preventDefault();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
formbricks.setEmail("matti@example.com");
formbricks.setUserId("123456");
formbricks.setAttribute("Plan", "Premium");
}
router.push("/app");
};
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{" "}
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
start your 14-day free trial
</a>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={submitAction}>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-6 text-gray-900">
Email address
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Sign in
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Facebook</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Twitter</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with GitHub</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
return (
<div className="h-full bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your in-app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<button
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 dark:text-slate-300 sm:flex sm:items-center sm:text-base">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-gray-300">
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-gray-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text only</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS ID");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + Css ID</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS Class");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + CSS Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID and Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID + Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class only");
}}>
Class only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-1 css-2 mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class + Class");
}}>
Class + Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class + Class</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Example on overriding packages/js colors */
.dark {
--fb-brand-color: red;
--fb-brand-text-color: white;
--fb-border-color: green;
--fb-border-color-highlight: var(--slate-500);
--fb-focus-color: red;
--fb-heading-color: yellow;
--fb-subheading-color: green;
--fb-info-text-color: orange;
--fb-signature-text-color: blue;
--fb-survey-background-color: black;
--fb-accent-background-color: rgb(13, 13, 12);
--fb-accent-background-color-selected: red;
--fb-placeholder-color: white;
--fb-shadow-color: yellow;
--fb-rating-fill: var(--yellow-300);
--fb-rating-hover: var(--yellow-500);
--fb-back-btn-border: currentColor;
--fb-submit-btn-border: transparent;
--fb-rating-selected: black;
}

View File

@@ -0,0 +1,85 @@
import { Fence } from "@/components/shared/Fence";
export const metadata = {
title: "Formbricks Responses API Documentation - Manage Your Survey Data Seamlessly",
description:
"Unlock the full potential of Formbricks' Client Actions API. Create Actions right from the API.",
};
#### Client API
# Actions API
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
This API can be used to:
- [Add Action for User](#add-action-for-user)
---
## Add Action for User {{ tag: 'POST', label: '/api/v1/client/<environment-id>/actions' }}
Adds an Actions for a given User by their User ID
<Row>
<Col>
### Mandatory Body Fields
<Properties>
<Property name="userId" type="string">
The id of the user for whom the action is being created.
</Property>
<Property name="name" type="string">
The name of the Action being created.
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="POST" label="/api/v1/client/<environment-id>/actions">
```bash {{ title: 'cURL' }}
curl --location --request POST 'https://app.formbricks.com/api/v1/client/<environment-id>/actions' \
--data-raw '{
"userId": "1",
"name": "new_action_v2"
}'
```
```json {{ title: 'Example Request Body' }}
{
"userId": "1",
"name": "new_action_v2"
}
```
</CodeGroup>
<CodeGroup title="Response">
```json {{ title: '200 Success' }}
{
"data": {}
}
```
```json {{ title: '400 Bad Request' }}
{
"code": "bad_request",
"message": "Fields are missing or incorrectly formatted",
"details": {
"name": "Required"
}
}
```
</CodeGroup>
</Col>
</Row>
---

View File

@@ -3,7 +3,7 @@ import { Fence } from "@/components/shared/Fence";
export const metadata = {
title: "Formbricks Public Client API Guide: Manage Survey Displays & Responses",
description:
"Dive deep into Formbricks' Public Client API designed for customisation. This comprehensive guide provides detailed instructions on how to mark surveys as displayed as well as responded for individual persons, ensuring seamless client-side interactions without compromising data security.",
"Dive deep into Formbricks' Public Client API designed for customisation. This comprehensive guide provides detailed instructions on how to mark create and update survey displays for users.",
};
#### Client API
@@ -13,17 +13,17 @@ export const metadata = {
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
This set of API can be used to
- [Mark Survey as Displayed](#mark-survey-as-displayed-for-person)
- [Mark Survey as Responded](#mark-survey-as-responded-for-person)
- [Create Display](#create-display)
- [Update Display](#update-display)
---
## Mark Survey as Displayed for Person {{ tag: 'POST', label: '/api/v1/client/diplays' }}
## Create Display {{ tag: 'POST', label: '/api/v1/client/<environment-id>/diplays' }}
<Row>
<Col>
Mark a Survey as seen for a Person provided valid SurveyId and PersonId.
Create Display of survey for a user
### Mandatory Request Body JSON Keys
<Properties>
@@ -32,25 +32,30 @@ This set of API can be used to
</Property>
</Properties>
### Optional Request Body JSON Keys
<Properties>
<Property name="personId" type="string">
Person ID for whom mark a survey as viewed
<Property name="userId" type="string">
Already existing user's ID to mark as viewed for a survey
</Property>
<Property name="responseId" type="string">
Already existing response's ID to link with this new Display
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="POST" label="/api/v1/displays">
<CodeGroup title="Request" tag="POST" label="/api/v1/client/<environment-id>/displays">
```bash {{ title: 'cURL' }}
curl -X POST \
'https://app.formbricks.com/api/v1/client/displays' \
-H 'Content-Type: application/json' \
-d '{
"surveyId": "<survey-id>",
"personId": "<person-id>"
}'
"surveyId":"<survey-id>",
"userId":"<user-id>"
}'
```
</CodeGroup>
@@ -60,29 +65,13 @@ This set of API can be used to
```json {{title:'200 Success'}}
{
"data": {
"id": "clm4qiygr00uqs60h5f5ola5h",
"createdAt": "2023-09-04T10:24:36.603Z",
"updatedAt": "2023-09-04T10:24:36.603Z",
"surveyId": "<survey-id>",
"person": {
"id": "<person-id>",
"attributes": {
"userId": "CYO600",
"email": "wei@google.com",
"Name": "Wei Zhu",
"Role": "Manager",
"Company": "Google",
"Experience": "2 years",
"Usage Frequency": "Daily",
"Company Size": "2401 employees",
"Product Satisfaction Score": "4",
"Recommendation Likelihood": "3"
},
"createdAt": "2023-08-08T18:05:01.483Z",
"updatedAt": "2023-08-08T18:05:01.483Z"
},
"status": "seen"
}
"id": "clp83r8uy000ceyqcbld2ebwj",
"createdAt": "2023-11-21T08:57:23.866Z",
"updatedAt": "2023-11-21T08:57:23.866Z",
"surveyId": "cloqzeuu70000z8khcirufo60",
"responseId": null,
"personId": "cloo25v3e0000z8ptskh030jd"
}
}
```
@@ -102,22 +91,36 @@ This set of API can be used to
---
## Mark Survey as Responded for Person {{ tag: 'POST', label: '/api/v1/client/diplays/[displayId]/responded' }}
## Update Display {{ tag: 'PUT', label: '/api/v1/client/<environment-id>/diplays/<display-id>' }}
<Row>
<Col>
Mark a Displayed Survey as responded for a Person.
Update a display by it's ID
### Optional Request Body JSON Keys
<Properties>
<Property name="userId" type="string">
Already existing user's ID to mark as viewed for a survey
</Property>
<Property name="responseId" type="string">
Already existing response's ID to link with this new Display
</Property>
</Properties>
</Col>
<Col sticky>
<CodeGroup title="Request" tag="POST" label="/api/v1/client/diplays/[displayId]/responded">
<CodeGroup title="Request" tag="PUT" label="/api/v1/client/<environment-id>/displays/<display-id>">
```bash {{ title: 'cURL' }}
curl -X POST \
--location \
'https://app.formbricks.com/api/v1/client/displays/<displayId>/responded'
'https://app.formbricks.com/api/v1/client/<environment-id>/displays/<display-id>' \
-H 'Content-Type: application/json' \
-d '{
"userId":"<user-id>"
}'
```
</CodeGroup>
@@ -127,37 +130,23 @@ This set of API can be used to
```json {{title:'200 Success'}}
{
"data": {
"id": "<displayId>",
"createdAt": "2023-09-04T10:24:36.603Z",
"updatedAt": "2023-09-04T10:33:56.978Z",
"surveyId": "<surveyId>",
"person": {
"id": "<personId>",
"attributes": {
"userId": "CYO600",
"email": "wei@google.com",
"Name": "Wei Zhu",
"Role": "Manager",
"Company": "Google",
"Experience": "2 years",
"Usage Frequency": "Daily",
"Company Size": "2401 employees",
"Product Satisfaction Score": "4",
"Recommendation Likelihood": "3"
},
"createdAt": "2023-08-08T18:05:01.483Z",
"updatedAt": "2023-08-08T18:05:01.483Z"
},
"status": "responded"
"id": "clp83r8uy000ceyqcbld2ebwj",
"createdAt": "2023-11-21T08:57:23.866Z",
"updatedAt": "2023-11-21T09:05:27.285Z",
"surveyId": "cloqzeuu70000z8khcirufo60",
"responseId": null,
"personId": "cloo25v3e0000z8ptskh030jd"
}
}
```
```json {{ title: '500 Internal Server Error' }}
```json {{ title: '400 Bad Request' }}
{
"code": "internal_server_error",
"message": "Database operation failed",
"details": {}
"code": "bad_request",
"message": "Fields are missing or incorrectly formatted",
"details": {
"surveyId": "Required"
}
}
```
</CodeGroup>

View File

@@ -146,7 +146,7 @@ This set of API can be used to
{
"id": "lkjaxb73ulydzeumhd51sx9g",
"type": "openText",
"headline": "What is the main benefit your receive from My Product?",
"headline": "What is the main benefit you receive from My Product?",
"required": true
},
{

View File

@@ -271,6 +271,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Overview", href: "/docs/api/client/overview" },
{ title: "Displays", href: "/docs/api/client/displays" },
{ title: "Responses", href: "/docs/api/client/responses" },
{ title: "Actions", href: "/docs/api/client/actions" },
],
},
{

View File

@@ -134,7 +134,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from Formbricks?",
headline: "What is the main benefit you receive from Formbricks?",
inputType: "text",
longAnswer: true,
required: true,

View File

@@ -30,6 +30,7 @@ export default function MetaInformation({
<meta name="image" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image" content={`https://${BASE_URL}/social-image.png`} />
<link rel="icon" type="image/x-icon" href={`https://${BASE_URL}/favicon.ico`} />
<link rel="canonical" href="https://formbricks.com/" />
<meta name="msapplication-TileColor" content="#00C4B8" />
<meta name="msapplication-TileImage" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image:alt" content="Open Source Experience Management, Privacy-first" />

1
apps/web/.env Symbolic link
View File

@@ -0,0 +1 @@
../../.env

View File

@@ -9,7 +9,11 @@ interface WidgetStatusIndicatorProps {
}
export default async function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
const [environment] = await Promise.all([getEnvironment(environmentId)]);
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const stati = {
notImplemented: {

View File

@@ -27,6 +27,11 @@ export function EditFormbricksBranding({
);
const [updatingBranding, setUpdatingBranding] = useState(false);
const getTextFromType = (type) => {
if (type === "linkSurvey") return "Link Surveys";
if (type === "inAppSurvey") return "In App Surveys";
};
const toggleBranding = async () => {
try {
setUpdatingBranding(true);
@@ -52,8 +57,8 @@ export function EditFormbricksBranding({
<div className="mb-4">
<Alert>
<AlertDescription>
To remove the Formbricks branding from the <span className="font-semibold">{type} surveys</span>
, please{" "}
To remove the Formbricks branding from the&nbsp;
<span className="font-semibold">{getTextFromType(type)}</span>, please&nbsp;
{type === "linkSurvey" ? (
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade your plan.</Link>

View File

@@ -1,5 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
@@ -32,3 +33,13 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg
}
return await sendEmbedSurveyPreviewEmail(to, subject, html);
};
export const getEmailHtmlAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return await getEmailTemplateHtml(surveyId);
};

View File

@@ -1,17 +1,17 @@
"use client";
import LinkTab from "./shareEmbedTabs/LinkTab";
import EmailTab from "./shareEmbedTabs/EmailTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { useMemo, useState } from "react";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { cn } from "@formbricks/lib/cn";
import { DialogContent, Dialog } from "@formbricks/ui/Dialog";
import { Button } from "@formbricks/ui/Button";
import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
import { TProduct } from "@formbricks/types/product";
import { TProfile } from "@formbricks/types/profile";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
import { CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import EmailTab from "./shareEmbedTabs/EmailTab";
import LinkTab from "./shareEmbedTabs/LinkTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
interface ShareEmbedSurveyProps {
survey: TSurvey;
@@ -43,16 +43,6 @@ export default function ShareEmbedSurvey({
const [activeId, setActiveId] = useState(tabs[0].id);
const componentMap = {
link: isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
),
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={surveyBrandColor} />,
webpage: <WebpageTab surveyUrl={surveyUrl} />,
};
return (
<Dialog
open={open}
@@ -87,7 +77,15 @@ export default function ShareEmbedSurvey({
</div>
<div className="flex w-full grow flex-col gap-6 bg-gray-50 px-4 py-6 lg:p-6">
<div className="flex h-full overflow-y-scroll lg:h-[590px] lg:overflow-y-visible">
{componentMap[activeId]}
{isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : activeId === "link" ? (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
) : activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebpageTab surveyUrl={surveyUrl} />
) : null}
</div>
<div className="mx-auto flex max-w-max rounded-md bg-slate-100 p-1 lg:hidden">
{tabs.slice(0, 2).map((tab) => (

View File

@@ -1,59 +1,55 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { AuthenticationError } from "@formbricks/types/errors";
import { sendEmailAction } from "../../actions";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
import {
Column,
Container,
Button as EmailButton,
Link,
Row,
Section,
Tailwind,
Text,
render,
Img,
} from "@react-email/components";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
import { getEmailHtmlAction, sendEmailAction } from "../../actions";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
interface EmailTabProps {
survey: TSurvey;
surveyUrl: string;
surveyId: string;
email: string;
brandColor: string;
}
export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) {
export default function EmailTab({ surveyId, email }: EmailTabProps) {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
getData();
async function getData() {
const emailHtml = await getEmailHtmlAction(surveyId);
setEmailHtmlPreview(emailHtml);
}
});
const subject = "Formbricks Email Survey Preview";
const emailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: false });
}, []);
const previewEmailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: true });
}, []);
const sendPreviewEmail = async () => {
const sendPreviewEmail = async (html) => {
try {
await sendEmailAction({ html: previewEmailValues.html, subject, to: email });
await sendEmailAction({
html,
subject,
to: email,
});
toast.success("Email sent!");
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error("You are not authenticated to perform this action.");
return;
}
toast.error("Something went wrong. Please try again later.");
}
};
@@ -68,7 +64,7 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
aria-label="Embed survey in your website"
onClick={() => {
toast.success("Embed code copied to clipboard!");
navigator.clipboard.writeText(emailValues.html);
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0"
EndIcon={DocumentDuplicateIcon}>
@@ -76,12 +72,11 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</Button>
) : (
<>
<Input type="email" placeholder="user@mail.com" className="h-11 grow bg-white" value={email} />
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={sendPreviewEmail}
onClick={() => sendPreviewEmail(emailHtmlPreview)}
EndIcon={EnvelopeIcon}
className="shrink-0">
Send Preview
@@ -92,7 +87,9 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
variant="darkCTA"
title="view embed code for email"
aria-label="view embed code for email"
onClick={() => setShowEmbed(!showEmbed)}
onClick={() => {
setShowEmbed(!showEmbed);
}}
EndIcon={CodeBracketIcon}
className="shrink-0">
{showEmbed ? "Hide Embed Code" : "View Embed Code"}
@@ -104,10 +101,10 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html"
showCopyToClipboard={false}>
{emailValues.html}
{emailHtml}
</CodeBlock>
) : (
<div className="">
<div>
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
@@ -118,7 +115,13 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
To : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">Subject : {subject}</div>
<div className="p-4">{previewEmailValues.Component}</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
@@ -126,308 +129,3 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</div>
);
}
const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview);
const html = render(Template, { pretty: true });
const htmlWithoutDoctype = html.replace(doctype, "");
return { Component: Template, html: htmlWithoutDoctype };
};
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -0,0 +1,319 @@
import { cn } from "@formbricks/lib/cn";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { isLight } from "@/app/lib/utils";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import {
Column,
Container,
Button as EmailButton,
Img,
Link,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
interface EmailTemplateProps {
survey: TSurvey;
surveyUrl: string;
brandColor: string;
}
export const getEmailTemplateHtml = async (surveyId) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const product = await getProductByEnvironmentId(survey.environmentId);
if (!product) {
throw new Error("Product not found");
}
const brandColor = product.brandColor;
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
pretty: true,
});
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
};
const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => {
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -9,7 +9,6 @@ import {
CheckCircleIcon,
ComputerDesktopIcon,
DevicePhoneMobileIcon,
EnvelopeIcon,
ExclamationCircleIcon,
LinkIcon,
} from "@heroicons/react/24/solid";
@@ -59,7 +58,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
id: "link",
name: "Link survey",
icon: LinkIcon,
description: "Share a link to a survey page.",
description: "Share a link to a survey page or embed it in a web page or email.",
comingSoon: false,
alert: false,
},
@@ -71,14 +70,6 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
comingSoon: true,
alert: false,
},
{
id: "email",
name: "Email",
icon: EnvelopeIcon,
description: "Send email surveys to your user base with your current email provider.",
comingSoon: true,
alert: false,
},
];
return (

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
import { Label } from "@formbricks/ui/Label";
import { getPlacementStyle } from "@/app/lib/preview";
import { cn } from "@formbricks/lib/cn";
import { TPlacement } from "@formbricks/types/common";
import { Label } from "@formbricks/ui/Label";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
@@ -18,8 +18,8 @@ type TPlacementProps = {
setCurrentPlacement: (placement: TPlacement) => void;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutside: (clickOutside: boolean) => void;
clickOutside: boolean;
setClickOutsideClose: (clickOutside: boolean) => void;
clickOutsideClose: boolean;
};
export default function Placement({
@@ -27,8 +27,8 @@ export default function Placement({
currentPlacement,
setOverlay,
overlay,
setClickOutside,
clickOutside,
setClickOutsideClose,
clickOutsideClose,
}: TPlacementProps) {
return (
<>
@@ -78,8 +78,8 @@ export default function Placement({
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(value) => setClickOutside(value === "allow")}
value={clickOutside ? "allow" : "disallow"}
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />

View File

@@ -19,7 +19,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
const [open, setOpen] = useState(false);
const { type, productOverwrites } = localSurvey;
const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {};
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
productOverwrites ?? {};
const togglePlacement = () => {
setLocalSurvey({
@@ -93,12 +94,12 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
});
};
const handleClickOutside = (clickOutside: boolean) => {
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
clickOutside,
clickOutsideClose,
},
});
};
@@ -163,8 +164,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={darkOverlay ? "dark" : "light"}
setClickOutside={handleClickOutside}
clickOutside={!!clickOutside}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
</div>
</div>

View File

@@ -12,8 +12,8 @@ import { TSurvey } from "@formbricks/types/surveys";
import { TProduct } from "@formbricks/types/product";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { TMembershipRole } from "@formbricks/types/memberships";
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
interface SurveyEditorProps {
survey: TSurvey;
@@ -41,6 +41,7 @@ export default function SurveyEditor({
useEffect(() => {
if (survey) {
if (localSurvey) return;
setLocalSurvey(JSON.parse(JSON.stringify(survey)));
if (survey.questions.length > 0) {
@@ -59,7 +60,7 @@ export default function SurveyEditor({
}, [localSurvey?.type]);
if (!localSurvey) {
return <ErrorComponent />;
return <Loading />;
}
return (

View File

@@ -97,6 +97,7 @@ export default function WhenToSendCard({
};
useEffect(() => {
if (isAddEventModalOpen) return;
if (activeIndex !== null) {
const newActionClass = actionClassArray[actionClassArray.length - 1].name;
const currentActionClass = localSurvey.triggers[activeIndex];

View File

@@ -15,7 +15,7 @@ import { SplitIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createSurveyAction } from "../actions";
import { customSurvey, templates } from "./templates";
import { customSurvey, templates, testTemplate } from "./templates";
type TemplateList = {
environmentId: string;
@@ -147,7 +147,10 @@ export default function TemplateList({
</div>
)}
</button>
{filteredTemplates.map((template: TTemplate) => (
{(process.env.NODE_ENV === "development"
? [...filteredTemplates, testTemplate]
: filteredTemplates
).map((template: TTemplate) => (
<div
onClick={() => {
const newTemplate = replacePresetPlaceholders(template, product);

View File

@@ -21,6 +21,308 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
timeToFinish: true,
};
export const testTemplate: TTemplate = {
name: "Test template",
description: "Test template consisting of all questions",
preset: {
name: "Test template",
questions: [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: true,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: true,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: false,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: true,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: false,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: true,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: false,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: true,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: false,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option 1",
},
{
id: createId(),
label: "Option 2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: true,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: false,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: true,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: false,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: true,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: false,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
],
thankYouCard: thankYouCardDefault,
welcomeCard: {
enabled: false,
timeToFinish: false,
},
hiddenFields: {
enabled: false,
},
},
};
export const templates: TTemplate[] = [
{
name: "Product Market Fit (Superhuman)",
@@ -104,7 +406,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from {{productName}}?",
headline: "What is the main benefit you receive from {{productName}}?",
required: true,
inputType: "text",
},

View File

@@ -1,11 +1,10 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@formbricks/lib/env.mjs";
import { formbricksEnabled, updateResponse } from "@/app/lib/formbricks";
import { cn } from "@formbricks/lib/cn";
import { TProfileObjective } from "@formbricks/types/profile";
import { TProfile } from "@formbricks/types/profile";
import { env } from "@formbricks/lib/env.mjs";
import { TProfile, TProfileObjective } from "@formbricks/types/profile";
import { Button } from "@formbricks/ui/Button";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
@@ -129,7 +128,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-400" variant="minimal" onClick={skip} id="objective-skip">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="objective-skip">
Skip
</Button>
<Button

View File

@@ -1,5 +1,8 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { TProduct } from "@formbricks/types/product";
import { TProfile } from "@formbricks/types/profile";
import { Logo } from "@formbricks/ui/Logo";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { Session } from "next-auth";
@@ -10,9 +13,6 @@ import Greeting from "./Greeting";
import Objective from "./Objective";
import Product from "./Product";
import Role from "./Role";
import { TProfile } from "@formbricks/types/profile";
import { TProduct } from "@formbricks/types/product";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
const MAX_STEPS = 6;
@@ -75,7 +75,7 @@ export default function Onboarding({ session, environmentId, profile, product }:
</div>
<div className="col-span-2 flex items-center justify-center gap-8">
<div className="relative grow overflow-hidden rounded-full bg-slate-200">
<ProgressBar progress={percent} barColor="bg-brand" height={2} />
<ProgressBar progress={percent} barColor="bg-brand-dark" height={2} />
</div>
<div className="grow-0 text-xs font-semibold text-slate-700">
{currentStep < 5 ? <>{Math.floor(percent * 100)}% complete</> : <>Almost there!</>}

View File

@@ -10,6 +10,7 @@ import { Label } from "@formbricks/ui/Label";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
type Product = {
done: () => void;
@@ -73,6 +74,10 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
if (!product) {
return <ErrorComponent />;
}
const buttonStyle = {
backgroundColor: color,
color: isLight(color) ? "black" : "white",
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
@@ -140,7 +145,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
</fieldset>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="pointer-events-none" style={{ backgroundColor: color }}>
<Button className="pointer-events-none" style={buttonStyle}>
Next
</Button>
</div>

View File

@@ -1,9 +1,9 @@
"use client";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@formbricks/lib/env.mjs";
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
import { cn } from "@formbricks/lib/cn";
import { env } from "@formbricks/lib/env.mjs";
import { Button } from "@formbricks/ui/Button";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
@@ -122,7 +122,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
</div>
</div>
<div className="mb-24 flex justify-between">
<Button size="lg" className="text-slate-400" variant="minimal" onClick={skip} id="role-skip">
<Button size="lg" className="text-slate-500" variant="minimal" onClick={skip} id="role-skip">
Skip
</Button>
<Button

View File

@@ -6,122 +6,78 @@ import { NextResponse } from "next/server";
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email";
import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types";
const BATCH_SIZE = 10;
export async function POST(): Promise<NextResponse> {
// check authentication with x-api-key header and CRON_SECRET env variable
// Check authentication
if (headers().get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
// list of email sending promises to wait for
const emailSendingPromises: Promise<void>[] = [];
const products = await getProducts();
// Fetch all team IDs
const teamIds = await getTeamIds();
// iterate through the products and send weekly summary email to each team member
for await (const product of products) {
// check if there are team members that have weekly summary notification enabled
const teamMembers = product.team.memberships;
const teamMembersWithNotificationEnabled = teamMembers.filter((member) => {
return (
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[product.id]
);
});
// if there are no team members with weekly summary notification enabled, skip to the next product (do not send email)
if (teamMembersWithNotificationEnabled.length == 0) {
continue;
}
// calculate insights for the product
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
// Paginate through teams
for (let i = 0; i < teamIds.length; i += BATCH_SIZE) {
const batchedTeamIds = teamIds.slice(i, i + BATCH_SIZE);
// Fetch products for batched teams asynchronously
const batchedProductsPromises = batchedTeamIds.map((teamId) => getProductsByTeamId(teamId));
// if there were no responses in the last 7 days, send a different email
if (notificationResponse.insights.numLiveSurvey == 0) {
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
const batchedProducts = await Promise.all(batchedProductsPromises);
for (const products of batchedProducts) {
for (const product of products) {
const teamMembers = product.team.memberships;
const teamMembersWithNotificationEnabled = teamMembers.filter(
(member) =>
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[product.id]
);
}
continue;
}
// send weekly summary email
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(teamMember.user.email, notificationResponse)
);
if (teamMembersWithNotificationEnabled.length === 0) continue;
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
if (notificationResponse.insights.numLiveSurvey === 0) {
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
);
}
continue;
}
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(teamMember.user.email, notificationResponse)
);
}
}
}
}
// wait for all emails to be sent
await Promise.all(emailSendingPromises);
return responses.successResponse({}, true);
}
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: Survey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const surveyData: Survey = {
id: survey.id,
name: survey.name,
status: survey.status,
responseCount: survey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of survey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 1) {
break;
}
const surveyResponse: SurveyResponse = {};
for (const question of survey.questions) {
const headline = question.headline;
const answer = response.data[question.id]?.toString() || null;
if (answer === null || answer === "" || answer?.length === 0) {
continue;
}
surveyResponse[headline] = answer;
}
surveyData.responses.push(surveyResponse);
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
productName: productName,
surveys,
insights,
};
const getTeamIds = async (): Promise<string[]> => {
const teams = await prisma.team.findMany({
select: {
id: true,
},
});
return teams.map((team) => team.id);
};
const getProducts = async (): Promise<ProductData[]> => {
// gets all products together with team members, surveys, responses, and displays for the last 7 days
const getProductsByTeamId = async (teamId: string): Promise<ProductData[]> => {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return await prisma.product.findMany({
where: {
teamId: teamId,
},
select: {
id: true,
name: true,
@@ -204,3 +160,63 @@ const getProducts = async (): Promise<ProductData[]> => {
},
});
};
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: Survey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const surveyData: Survey = {
id: survey.id,
name: survey.name,
status: survey.status,
responseCount: survey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of survey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 1) {
break;
}
const surveyResponse: SurveyResponse = {};
for (const question of survey.questions) {
const headline = question.headline;
const answer = response.data[question.id]?.toString() || null;
if (answer === null || answer === "" || answer?.length === 0) {
continue;
}
surveyResponse[headline] = answer;
}
surveyData.responses.push(surveyResponse);
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
productName: productName,
surveys,
insights,
};
};

View File

@@ -0,0 +1,95 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
params: {
userId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { userId, environmentId } = context.params;
const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { key, value } = inputValidation.data;
const person = await getPerson(personId);
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
personCache.revalidate({
id: personId,
environmentId,
});
surveyCache.revalidate({
environmentId,
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -1,5 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { markDisplayResponded } from "@formbricks/lib/display/service";
import { markDisplayRespondedLegacy } from "@formbricks/lib/display/service";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -14,7 +14,7 @@ export async function POST(_: Request, { params }: { params: { displayId: string
}
try {
const display = await markDisplayResponded(displayId);
const display = await markDisplayRespondedLegacy(displayId);
return responses.successResponse(
{
...display,

View File

@@ -1,8 +1,8 @@
import { responses } from "@/app/lib/api/response";
import { updateDisplay } from "@formbricks/lib/display/service";
import { TDisplayCreateInput, ZDisplayUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { updateDisplayLegacy } from "@formbricks/lib/display/service";
import { ZDisplayLegacyUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -16,8 +16,8 @@ export async function PUT(
if (!displayId) {
return responses.badRequestResponse("Missing displayId", undefined, true);
}
const displayInput: TDisplayCreateInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse(displayInput);
const displayInput = await request.json();
const inputValidation = ZDisplayLegacyUpdateInput.safeParse(displayInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,7 +27,7 @@ export async function PUT(
);
}
try {
const display = await updateDisplay(displayId, inputValidation.data);
const display = await updateDisplayLegacy(displayId, inputValidation.data);
return responses.successResponse(display, true);
} catch (error) {
console.error(error);

View File

@@ -1,11 +1,13 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
@@ -37,9 +39,9 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
const { key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
const person = await getPerson(personId);
if (!existingPerson) {
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
@@ -66,7 +68,23 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
environmentId,
});
const state = await getUpdatedState(environmentId, personId);
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {

View File

@@ -1,27 +1,27 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponseLegacy } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseLegacyInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
import { TSurvey } from "@formbricks/types/surveys";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
const responseInput = await request.json();
if (responseInput.personId === "legacy") {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -67,7 +67,7 @@ export async function POST(request: Request): Promise<NextResponse> {
responseInput.personId = null;
}
response = await createResponse({
response = await createResponseLegacy({
...responseInput,
meta,
});

View File

@@ -1,116 +0,0 @@
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { displayCache } from "@formbricks/lib/display/cache";
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { productCache } from "@formbricks/lib/product/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { unstable_cache } from "next/cache";
// Helper function to calculate difference in days between two dates
const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
},
[`getSyncSurveysCached-${environmentId}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -1,133 +0,0 @@
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/lib/surveys";
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState } from "@formbricks/types/js";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
const captureNewSessionTelemetry = async (jsVersion?: string): Promise<void> => {
await captureTelemetry("state update", { jsVersion: jsVersion ?? "unknown" });
};
export const getUpdatedState = async (
environmentId: string,
personId: string,
jsVersion?: string
): Promise<TJsLegacyState> => {
let environment: TEnvironment | null;
if (jsVersion) {
captureNewSessionTelemetry(jsVersion);
}
// check if environment exists
environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
if (!environment?.widgetSetupCompleted) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
// check if Monthly Active Users limit is reached
if (IS_FORMBRICKS_CLOUD) {
const hasUserTargetingSubscription =
team?.billing?.features.userTargeting.status &&
team?.billing?.features.userTargeting.status in ["active", "canceled"];
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
if (isMauLimitReached) {
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
throw new Error(errorMessage);
// if (!personId) {
// // don't allow new people
// throw new Error(errorMessage);
// }
// const session = await getSession(sessionId);
// if (!session) {
// // don't allow new sessions
// throw new Error(errorMessage);
// }
// // check if session was created this month (user already active this month)
// const now = new Date();
// const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// if (new Date(session.createdAt) < firstDayOfMonth) {
// throw new Error(errorMessage);
// }
}
}
const person = await getPerson(personId);
if (!person) {
throw new Error("Person not found");
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveysCached(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsLegacyState = {
person: person!,
session: {},
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return state;
};
export const getPublicUpdatedState = async (environmentId: string) => {
// check if environment exists
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
// TODO: check if Monthly Active Users limit is reached
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveys(environmentId),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
const state: TJsLegacyState = {
surveys,
session: {},
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
person: null,
};
return state;
};

View File

@@ -1,4 +1,4 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";

View File

@@ -1,7 +1,7 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
@@ -26,10 +26,12 @@ export async function POST(req: Request): Promise<NextResponse> {
const { environmentId, userId } = inputValidation.data;
const personWithUserId = await getOrCreatePersonByUserId(userId, environmentId);
const state = await getUpdatedState(environmentId, personWithUserId.id);
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
person = await createPerson(environmentId, userId);
}
const state = await getUpdatedState(environmentId, person.id);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);

View File

@@ -0,0 +1,11 @@
import { TJsLegacyState, TJsState } from "@formbricks/types/js";
export const transformLegacySurveys = (state: TJsState): TJsLegacyState => {
const updatedState: any = { ...state };
updatedState.surveys = updatedState.surveys.map((survey) => {
const updatedSurvey = { ...survey };
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
return updatedSurvey;
});
return { ...updatedState, session: {} };
};

View File

@@ -1,116 +0,0 @@
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { displayCache } from "@formbricks/lib/display/cache";
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { productCache } from "@formbricks/lib/product/cache";
import { getSurveys } from "@formbricks/lib/survey/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { unstable_cache } from "next/cache";
// Helper function to calculate difference in days between two dates
const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
},
[`getSyncSurveysCached-${environmentId}-${person.id}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -1,4 +1,3 @@
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/sync/lib/surveys";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import {
IS_FORMBRICKS_CLOUD,
@@ -9,15 +8,25 @@ import {
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service";
import {
getMonthlyActiveTeamPeopleCount,
getMonthlyTeamResponseCount,
getTeamByEnvironmentId,
} from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState } from "@formbricks/types/js";
import { TJsLegacyState, TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TSurvey } from "@formbricks/types/surveys";
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
const updatedSurveys = surveys.map((survey) => {
const updatedSurvey: any = { ...survey };
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
return updatedSurvey;
});
return updatedSurveys;
};
export const getUpdatedState = async (environmentId: string, personId?: string): Promise<TJsLegacyState> => {
let environment: TEnvironment | null;
@@ -85,12 +94,14 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
if (isAppSurveyLimitReached) {
surveys = [];
} else if (isPerson) {
surveys = await getSyncSurveysCached(environmentId, person as TPerson);
surveys = await getSyncSurveys(environmentId, person as TPerson);
} else {
surveys = await getSurveys(environmentId);
surveys = surveys.filter((survey) => survey.type === "web");
surveys = surveys.filter((survey) => survey.type === "web" && survey.status === "inProgress");
}
surveys = transformLegacySurveys(surveys);
// get/create rest of the state
const [noCodeActionClasses, product] = await Promise.all([
getActionClasses(environmentId),

View File

@@ -16,10 +16,11 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function PUT(request: Request, context: Context): Promise<NextResponse> {
const { displayId } = context.params;
const { displayId, environmentId } = context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {

View File

@@ -1,11 +1,12 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSyncSurveysCached } from "@formbricks/lib/survey/service";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/js";
@@ -43,52 +44,54 @@ export async function GET(
const { environmentId, userId } = inputValidation.data;
// check if person exists
const person = await getOrCreatePersonByUserId(userId, environmentId);
if (!person) {
return responses.badRequestResponse(`Person with userId ${userId} not found`);
}
let environment: TEnvironment | null;
// check if environment exists
environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
if (!environment?.widgetSetupCompleted) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
// check if Monthly Active Users limit is reached
// check if MAU limit is reached
let isMauLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
const hasUserTargetingSubscription =
team?.billing?.features.userTargeting.status &&
team?.billing?.features.userTargeting.status in ["active", "canceled"];
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
}
// TODO: Problem is that if isMauLimitReached, all sync request will fail
// But what we essentially want, is to fail only for new people syncing for the first time
if (isMauLimitReached) {
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
let person = await getPersonByUserId(environmentId, userId);
if (!isMauLimitReached) {
if (!person) {
person = await createPerson(environmentId, userId);
}
} else {
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
if (!person) {
// if it's a new person and MAU limit is reached, throw an error
throw new Error(errorMessage);
} else {
// check if person has been active this month
const latestAction = await getLatestActionByPersonId(person.id);
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
throw new Error(errorMessage);
}
}
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveysCached(environmentId, person),
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);

View File

@@ -0,0 +1,49 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createPerson, getPersonByUserId, updatePerson } from "@formbricks/lib/person/service";
import { ZPersonUpdateInput } from "@formbricks/types/people";
import { NextResponse } from "next/server";
interface Context {
params: {
userId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { userId, environmentId } = context.params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZPersonUpdateInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
// return responses.notFoundResponse("PersonByUserId", userId, true);
// HOTFIX: create person if not found to work around caching issue
person = await createPerson(environmentId, userId);
}
await updatePerson(person.id, inputValidation.data);
return responses.successResponse({}, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -6,6 +6,7 @@ import { getSurvey } from "@formbricks/lib/survey/service";
import { updateResponse } from "@formbricks/lib/response/service";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { NextResponse } from "next/server";
import { getPerson } from "@formbricks/lib/person/service";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -23,6 +24,13 @@ export async function PUT(
const responseUpdate = await request.json();
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
if (responseUpdate.personId && typeof responseUpdate.personId === "string") {
const person = await getPerson(responseUpdate.personId);
responseUpdate.userId = person?.userId;
delete responseUpdate.personId;
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {

View File

@@ -1,23 +1,50 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/errors";
import { getPerson } from "@formbricks/lib/person/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { ZId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
interface Context {
params: {
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
export async function POST(request: Request, context: Context): Promise<NextResponse> {
const { environmentId } = context.params;
const environmentIdValidation = ZId.safeParse(environmentId);
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
const responseInput = await request.json();
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
if (responseInput.personId && typeof responseInput.personId === "string") {
const person = await getPerson(responseInput.personId);
responseInput.userId = person?.userId;
delete responseInput.personId;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,17 +54,20 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
let survey;
try {
survey = await getSurvey(responseInput.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
const teamDetails = await getTeamDetails(survey.environmentId);
@@ -54,14 +84,8 @@ export async function POST(request: Request): Promise<NextResponse> {
},
};
// check if personId is anonymous
if (responseInput.personId === "anonymous") {
// remove this from the request
responseInput.personId = null;
}
response = await createResponse({
...responseInput,
...inputValidation.data,
meta,
});
} catch (error) {

View File

@@ -1,14 +1,19 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { authenticateRequest } from "@/app/api/v1/auth";
import { DatabaseError } from "@formbricks/types/errors";
import { NextRequest } from "next/server";
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
const surveyId = request.nextUrl.searchParams.get("surveyId");
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const responseArray = await getResponsesByEnvironmentId(authentication.environmentId!);
return responses.successResponse(responseArray);
let environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId!);
if (surveyId) {
environmentResponses = environmentResponses.filter((response) => response.surveyId === surveyId);
}
return responses.successResponse(environmentResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

View File

@@ -10,11 +10,11 @@ export const createResponse = async (
finished: boolean = false
): Promise<any> => {
const api = formbricks.getApi();
const personId = formbricks.getPerson()?.id;
const userId = formbricks.getPerson()?.userId;
return await api.client.response.create({
surveyId,
personId: personId ?? "",
userId: userId ?? "",
finished,
data,
});

View File

@@ -0,0 +1,15 @@
import rateLimit from "@/app/middleware/rateLimit";
import { CLIENT_SIDE_API_RATE_LIMIT, LOGIN_RATE_LIMIT, SIGNUP_RATE_LIMIT } from "@formbricks/lib/constants";
export const signUpLimiter = rateLimit({
interval: SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
});
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
});
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});

View File

@@ -0,0 +1,10 @@
export const loginRoute = (url: string) => url === "/api/auth/callback/credentials";
export const signupRoute = (url: string) => url === "/api/v1/users";
export const clientSideApiRoute = (url: string): boolean => {
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;
return regex.test(url);
};

View File

@@ -2,6 +2,7 @@ import { LRUCache } from "lru-cache";
type Options = {
interval: number;
allowedPerInterval: number;
};
export default function rateLimit(options: Options) {
@@ -20,7 +21,7 @@ export default function rateLimit(options: Options) {
tokenCount[0] += 1;
const currentUsage = tokenCount[0];
const isRateLimited = currentUsage >= 5;
const isRateLimited = currentUsage >= options.allowedPerInterval;
return isRateLimited ? reject() : resolve();
}),
};

View File

@@ -18,7 +18,7 @@ import { FormbricksAPI } from "@formbricks/api";
interface LinkSurveyProps {
survey: TSurvey;
product: TProduct;
personId?: string;
userId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
singleUseId?: string;
@@ -29,7 +29,7 @@ interface LinkSurveyProps {
export default function LinkSurvey({
survey,
product,
personId,
userId,
emailVerificationStatus,
prefillAnswer,
singleUseId,
@@ -41,9 +41,7 @@ export default function LinkSurvey({
const isPreview = searchParams?.get("preview") === "true";
const sourceParam = searchParams?.get("source");
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
const [surveyState, setSurveyState] = useState(
new SurveyState(survey.id, singleUseId, responseId, personId)
);
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId, userId));
const [activeQuestionId, setActiveQuestionId] = useState<string>(
survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id
);
@@ -85,21 +83,20 @@ export default function LinkSurvey({
}
}, []);
const [hiddenFieldsRecord, setHiddenFieldsRecord] = useState<Record<string, string | number | string[]>>();
const hiddenFieldsRecord = useMemo<Record<string, string | number | string[]> | null>(() => {
const fieldsRecord: Record<string, string | number | string[]> = {};
let fieldsSet = false;
useEffect(() => {
survey.hiddenFields?.fieldIds?.forEach((field) => {
// set the question and answer to the survey state
const answer = searchParams?.get(field);
if (answer) {
setHiddenFieldsRecord((prev) => {
return {
...prev,
[field]: answer,
};
});
fieldsRecord[field] = answer;
fieldsSet = true;
}
});
// Only return the record if at least one field was set.
return fieldsSet ? fieldsRecord : null;
}, [searchParams, survey.hiddenFields?.fieldIds]);
useEffect(() => {

View File

@@ -14,7 +14,7 @@ import { cn } from "@formbricks/lib/cn";
interface LinkSurveyPinScreenProps {
surveyId: string;
product: TProduct;
personId?: string;
userId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
singleUseId?: string;
@@ -28,7 +28,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
product,
webAppUrl,
emailVerificationStatus,
personId,
userId,
prefillAnswer,
singleUseId,
singleUseResponse,
@@ -103,7 +103,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
<LinkSurvey
survey={survey}
product={product}
personId={personId}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={prefillAnswer}
singleUseId={singleUseId}

View File

@@ -1,19 +1,19 @@
export const revalidate = REVALIDATION_INTERVAL;
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getEmailVerificationStatus } from "./lib/helpers";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import { notFound } from "next/navigation";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { TResponse } from "@formbricks/types/responses";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import type { Metadata } from "next";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { TResponse } from "@formbricks/types/responses";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getEmailVerificationStatus } from "./lib/helpers";
interface LinkSurveyPageProps {
params: {
@@ -146,9 +146,12 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
}
const userId = searchParams.userId;
let person;
if (userId) {
person = await getOrCreatePersonByUserId(userId, survey.environmentId);
// make sure the person exists or get's created
const person = await getPersonByUserId(survey.environmentId, userId);
if (!person) {
await createPerson(survey.environmentId, userId);
}
}
const isSurveyPinProtected = Boolean(!!survey && survey.pin);
@@ -158,7 +161,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
<PinScreen
surveyId={survey.id}
product={product}
personId={person?.id}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
@@ -172,7 +175,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
<LinkSurvey
survey={survey}
product={product}
personId={person?.id}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}

View File

@@ -1,10 +1,8 @@
import rateLimit from "@/app/(auth)/auth/rate-limit";
import { signUpLimiter, loginLimiter, clientSideApiEndpointsLimiter } from "@/app/middleware/bucket";
import { clientSideApiRoute, loginRoute, signupRoute } from "@/app/middleware/endpointValidator";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const signUpLimiter = rateLimit({ interval: 60 * 60 * 1000 }); // 60 minutes
const loginLimiter = rateLimit({ interval: 15 * 60 * 1000 }); // 15 minutes
export async function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
@@ -19,10 +17,12 @@ export async function middleware(request: NextRequest) {
if (ip) {
try {
if (request.nextUrl.pathname === "/api/auth/callback/credentials") {
if (loginRoute(request.nextUrl.pathname)) {
await loginLimiter.check(ip);
} else if (request.nextUrl.pathname === "/api/v1/users") {
} else if (signupRoute(request.nextUrl.pathname)) {
await signUpLimiter.check(ip);
} else if (clientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter.check(ip);
}
return res;
} catch (_e) {
@@ -35,5 +35,11 @@ export async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/api/auth/callback/credentials", "/api/v1/users"],
matcher: [
"/api/auth/callback/credentials",
"/api/v1/users",
"/api/(.*)/client/:path*",
"/api/v1/js/actions",
"/api/v1/client/storage",
],
};

View File

@@ -47,6 +47,11 @@ const nextConfig = {
destination: "/api/v1/management/surveys",
permanent: true,
},
{
source: "/api/v1/responses",
destination: "/api/v1/management/responses",
permanent: true,
},
{
source: "/api/v1/me",
destination: "/api/v1/management/me",

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.2.1",
"version": "1.3.1",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -11,7 +11,6 @@
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "^3.438.0",
"@formbricks/api": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/ee": "workspace:*",
@@ -26,42 +25,42 @@
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-email/components": "^0.0.9",
"@sentry/nextjs": "^7.77.0",
"@react-email/components": "^0.0.11",
"@sentry/nextjs": "^7.80.1",
"@vercel/og": "^0.5.20",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
"encoding": "^0.1.13",
"framer-motion": "10.16.4",
"framer-motion": "10.16.5",
"googleapis": "^128.0.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.0.1",
"lucide-react": "^0.290.0",
"lru-cache": "^10.0.2",
"lucide-react": "^0.292.0",
"mime": "^3.0.0",
"next": "13.5.6",
"nodemailer": "^6.9.7",
"otplib": "^12.0.1",
"posthog-js": "^1.87.3",
"posthog-js": "^1.91.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^1.9.5",
"react-hook-form": "^7.47.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
"react-icons": "^4.12.0",
"ua-parser-js": "^1.0.37",
"webpack": "^5.89.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/bcryptjs": "^2.4.5",
"@types/lodash": "^4.14.200",
"@types/markdown-it": "^13.0.5",
"@types/qrcode": "^1.5.4",
"@types/bcryptjs": "^2.4.6",
"@types/lodash": "^4.14.201",
"@types/markdown-it": "^13.0.6",
"@types/qrcode": "^1.5.5",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -32,9 +32,9 @@
"@changesets/cli": "^2.26.2",
"eslint-config-formbricks": "workspace:*",
"husky": "^8.0.3",
"lint-staged": "^15.0.1",
"lint-staged": "^15.1.0",
"rimraf": "^5.0.5",
"tsx": "^3.13.0",
"tsx": "^4.2.0",
"turbo": "^1.10.16"
},
"lint-staged": {

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/api",
"license": "MIT",
"version": "1.0.0",
"version": "1.1.0",
"description": "Formbricks-api is an api wrapper for the Formbricks client API",
"keywords": [
"Formbricks",
@@ -34,8 +34,8 @@
"@formbricks/types": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"terser": "^5.22.0",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"terser": "^5.24.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.3"
}
}

View File

@@ -0,0 +1,18 @@
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { TActionInput } from "@formbricks/types/actions";
import { makeRequest } from "../../utils/makeRequest";
export class ActionAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async create(actionInput: Omit<TActionInput, "environmentId">): Promise<Result<{}, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/actions`, "POST", actionInput);
}
}

View File

@@ -20,7 +20,7 @@ export class DisplayAPI {
async update(
displayId: string,
displayInput: TDisplayUpdateInput
displayInput: Omit<TDisplayUpdateInput, "environmentId">
): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(
this.apiHost,

View File

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

View File

@@ -0,0 +1,33 @@
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/makeRequest";
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
export class PeopleAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async create(userId: string): Promise<Result<TPerson, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/people`, "POST", {
environmentId: this.environmentId,
userId,
});
}
async update(
userId: string,
personInput: TPersonUpdateInput
): Promise<Result<TPerson, NetworkError | Error>> {
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/people/${userId}`,
"POST",
personInput
);
}
}

View File

@@ -1,7 +1,7 @@
import { makeRequest } from "../../utils/makeRequest";
import { NetworkError } from "@formbricks/types/errors";
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses";
import { makeRequest } from "../../utils/makeRequest";
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
@@ -14,7 +14,9 @@ export class ResponseAPI {
this.environmentId = environmentId;
}
async create(responseInput: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
async create(
responseInput: Omit<TResponseInput, "environmentId">
): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}

View File

@@ -25,7 +25,7 @@
"predev": "pnpm generate"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/client": "^5.6.0",
"@prisma/extension-accelerate": "^0.6.2",
"dotenv-cli": "^7.3.0"
},
@@ -33,9 +33,9 @@
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"prisma": "^5.4.2",
"prisma": "^5.6.0",
"prisma-dbml-generator": "^0.10.0",
"prisma-json-types-generator": "^3.0.2",
"prisma-json-types-generator": "^3.0.3",
"zod": "^3.22.4",
"zod-prisma": "^0.5.4"
}

View File

@@ -18,6 +18,6 @@
},
"dependencies": {
"@formbricks/lib": "workspace:*",
"stripe": "^14.0.0"
"stripe": "^14.5.0"
}
}

View File

@@ -8,8 +8,8 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"eslint": "^8.52.0",
"eslint-config-next": "^14.0.0",
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"eslint-config-prettier": "^9.0.0",
"eslint-config-turbo": "latest",
"eslint-plugin-react": "7.33.2",

View File

@@ -1,55 +0,0 @@
# @formbricks/js
## 1.1.4
### Patch Changes
- 24f5796c: various improvements & bugfixes
## 1.1.3
### Patch Changes
- d1172831: Multiple bugfixes and performance improvements
## 1.1.0
### Minor Changes
- e46b0588: Multiple bugfixes and performance improvements
## 1.0.6
### Patch Changes
- 8efb1054: Introduce response queue for instant question transitions
## 1.0.5
### Patch Changes
- bea1f993: Fix submit error in multiple choice questions
## 1.0.4
### Patch Changes
- 01523393: Convert all attributes and userIds to string in formbricks-js
## 1.0.3
### Patch Changes
- 3dde021c: Release version 1.0.2
## 1.0.2
### Patch Changes
- a1b447ca: Increase z-index to 999999 to increase compatibility with more websites
## 1.0.1
### Patch Changes
- 3d0d633b: Fix new Session event not triggered every time a new session is created

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.2.1",
"version": "1.2.3",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",
@@ -34,17 +34,17 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"@babel/preset-typescript": "^7.23.2",
"@babel/core": "^7.23.3",
"@babel/preset-env": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.5",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@types/jest": "^29.5.9",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"eslint-config-formbricks": "workspace:*",
@@ -52,9 +52,9 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"terser": "^5.22.0",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"terser": "^5.24.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.3"
},
"jest": {
"transformIgnorePatterns": [

View File

@@ -1,8 +1,10 @@
import { TJsActionInput, TSurveyWithTriggers } from "@formbricks/types/js";
import { TJsActionInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { renderWidget } from "./widget";
import { FormbricksAPI } from "@formbricks/api";
const logger = Logger.getInstance();
const config = Config.getInstance();
@@ -22,24 +24,23 @@ export const trackAction = async (
// don't send actions to the backend if the person is not identified
if (config.get().state?.person?.userId && !intentsToNotCreateOnApp.includes(name)) {
logger.debug(`Sending action "${name}" to backend`);
const res = await fetch(`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.action.create({
...input,
userId: config.get().state.person!.userId,
});
if (!res.ok) {
const error = await res.json();
return err({
code: "network_error",
message: `Error tracking action: ${JSON.stringify(error)}`,
status: res.status,
url: res.url,
responseMessage: error.message,
message: `Error tracking action ${name}`,
status: 500,
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`,
responseMessage: res.error.message,
});
}
}
@@ -58,10 +59,10 @@ export const trackAction = async (
return okVoid();
};
export const triggerSurvey = (actionName: string, activeSurveys: TSurveyWithTriggers[]): void => {
export const triggerSurvey = (actionName: string, activeSurveys: TSurvey[]): void => {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (typeof trigger === "string" ? trigger === actionName : trigger.name === actionName) {
if (trigger === actionName) {
logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`);
renderWidget(survey);
return;

View File

@@ -14,7 +14,7 @@ import { addCleanupEventListeners, addEventListeners, removeAllEventListeners }
import { Logger } from "./logger";
import { checkPageUrl } from "./noCodeActions";
import { sync } from "./sync";
import { addWidgetContainer } from "./widget";
import { addWidgetContainer, closeSurvey } from "./widget";
import { trackAction } from "./actions";
const config = Config.getInstance();
@@ -128,6 +128,7 @@ export const checkInitialized = (): Result<void, NotInitializedError> => {
export const deinitalize = (): void => {
logger.debug("Deinitializing");
closeSurvey();
removeAllEventListeners();
config.resetConfig();
isInitialized = false;

View File

@@ -1,17 +1,11 @@
import { TJsPeopleAttributeInput, TJsState } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
import { Config } from "./config";
import {
AttributeAlreadyExistsError,
MissingPersonError,
NetworkError,
Result,
err,
ok,
okVoid,
} from "./errors";
import { AttributeAlreadyExistsError, MissingPersonError, NetworkError, Result, err, okVoid } from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -19,7 +13,7 @@ const logger = Logger.getInstance();
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
): Promise<Result<void, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person?.id) {
return err({
code: "missing_person",
@@ -27,37 +21,39 @@ export const updatePersonAttribute = async (
});
}
const input: TJsPeopleAttributeInput = {
key,
value,
const input: TPersonUpdateInput = {
attributes: {
[key]: value,
},
};
const res = await fetch(
`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
config.get().state.person?.id
}/set-attribute`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}
);
const resJson = await res.json();
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.people.update(config.get().state.person!.userId, input);
if (!res.ok) {
return err({
code: "network_error",
status: res.status,
message: "Error updating person",
url: res.url,
responseMessage: resJson.message,
status: 500,
message: `Error updating person with userId ${config.get().state.person?.userId}`,
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
config.get().state.person?.userId
}`,
responseMessage: res.error.message,
});
}
return ok(resJson.data as TJsState);
logger.debug("Attribute updated. Syncing...");
await sync({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().state.person?.userId,
});
return okVoid();
};
export const hasAttributeValue = (key: string, value: string): boolean => {
@@ -95,14 +91,6 @@ export const setPersonAttribute = async (
const result = await updatePersonAttribute(key, value.toString());
if (result.ok) {
const state = result.value;
config.update({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
state,
});
return okVoid();
}
@@ -115,6 +103,7 @@ export const logoutPerson = async (): Promise<void> => {
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const syncParams = {
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,

View File

@@ -1,13 +1,14 @@
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import SurveyState from "@formbricks/lib/surveyState";
import { renderSurveyModal } from "@formbricks/surveys";
import { TJSStateDisplay, TSurveyWithTriggers } from "@formbricks/types/js";
import { TJSStateDisplay } from "@formbricks/types/js";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
@@ -15,7 +16,7 @@ const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
export const renderWidget = (survey: TSurveyWithTriggers) => {
export const renderWidget = (survey: TSurvey) => {
if (surveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -45,7 +46,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
const productOverwrites = survey.productOverwrites ?? {};
const brandColor = productOverwrites.brandColor ?? product.brandColor;
const highlightBorderColor = productOverwrites.highlightBorderColor ?? product.highlightBorderColor;
const clickOutside = productOverwrites.clickOutside ?? product.clickOutsideClose;
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
const placement = productOverwrites.placement ?? product.placement;
const isBrandingEnabled = product.inAppSurveyBranding;
@@ -71,12 +72,13 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
const existingDisplays = config.get().state.displays;
const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay];
const previousConfig = config.get();
let state = filterPublicSurveys({
...previousConfig.state,
displays,
});
config.update({
...previousConfig,
state: {
...previousConfig.state,
displays,
},
state,
});
}
@@ -107,18 +109,19 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
if (!lastDisplay.responded) {
lastDisplay.responded = true;
const previousConfig = config.get();
let state = filterPublicSurveys({
...previousConfig.state,
displays,
});
config.update({
...previousConfig,
state: {
...previousConfig.state,
displays,
},
state,
});
}
}
if (config.get().state.person && config.get().state.person?.id) {
surveyState.updatePersonId(config.get().state.person?.id!);
if (config.get().state.person && config.get().state.person?.userId) {
surveyState.updateUserId(config.get().state.person?.userId!);
}
responseQueue.updateSurveyState(surveyState);
responseQueue.add({

View File

@@ -116,19 +116,9 @@ export const mockSetEmailIdResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: { userId: initialUserId, email: initialUserEmail },
},
id: initialPersonUid,
environmentId,
attributes: { userId: initialUserId, email: initialUserEmail },
},
})
);
@@ -138,22 +128,12 @@ export const mockSetCustomAttributeResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: initialUserEmail,
[customAttributeKey]: customAttributeValue,
},
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: initialUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})
@@ -164,16 +144,12 @@ export const mockUpdateEmailResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: updatedUserEmail,
[customAttributeKey]: customAttributeValue,
},
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: updatedUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})

View File

@@ -61,7 +61,7 @@ test("Formbricks should get the current person with no attributes", () => {
expect(Object.keys(currentStatePersonAttributes)).toHaveLength(0);
});
test("Formbricks should set email", async () => {
/* test("Formbricks should set email", async () => {
mockSetEmailIdResponse();
await formbricks.setEmail(initialUserEmail);
@@ -112,7 +112,7 @@ test("Formbricks should update attribute", async () => {
expect(email).toStrictEqual(updatedUserEmail);
const customAttribute = currentStatePersonAttributes[customAttributeKey];
expect(customAttribute).toStrictEqual(customAttributeValue);
});
}); */
test("Formbricks should track event", async () => {
mockEventTrackResponse();

View File

@@ -13,7 +13,7 @@ import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
import { getPersonByUserId } from "../person/service";
import { createPerson, getPersonByUserId } from "../person/service";
export const getLatestActionByEnvironmentId = async (environmentId: string): Promise<TAction | null> => {
const action = await unstable_cache(
@@ -40,7 +40,6 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
// sessionId: actionPrisma.sessionId,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
@@ -71,6 +70,60 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
: action;
};
export const getLatestActionByPersonId = async (personId: string): Promise<TAction | null> => {
const action = await unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const actionPrisma = await prisma.action.findFirst({
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
include: {
actionClass: true,
},
});
if (!actionPrisma) {
return null;
}
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
};
return action;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
},
[`getLastestActionByPersonId-${personId}`],
{
tags: [actionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
// since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them
// https://github.com/vercel/next.js/issues/51613
return action
? {
...action,
createdAt: new Date(action.createdAt),
}
: action;
};
export const getActionsByPersonId = async (personId: string, page?: number): Promise<TAction[]> => {
const actions = await unstable_cache(
async () => {
@@ -187,10 +240,11 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
actionType = "automatic";
}
const person = await getPersonByUserId(userId, environmentId);
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
throw new Error("Person not found");
// create person if it does not exist
person = await createPerson(environmentId, userId);
}
let actionClass = await getActionClassByEnvironmentIdAndName(environmentId, name);

View File

@@ -1,5 +1,4 @@
import "server-only";
import path from "path";
import { env } from "./env.mjs";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes
@@ -59,7 +58,7 @@ export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;
// Storage constants
export const UPLOADS_DIR = path.resolve("./uploads");
export const UPLOADS_DIR = "./uploads";
export const MAX_SIZES = {
public: 1024 * 1024 * 10, // 10MB
free: 1024 * 1024 * 10, // 10MB
@@ -75,5 +74,20 @@ export const LOCAL_UPLOAD_URL = {
// Pricing
export const PRICING_USERTARGETING_FREE_MTU = 2500;
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60 * 1000, // 60 minutes
allowedPerInterval: 5,
};
export const LOGIN_RATE_LIMIT = {
interval: 15 * 60 * 1000, // 15 minutes
allowedPerInterval: 5,
};
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 10 * 60 * 1000, // 60 minutes
allowedPerInterval: 50,
};
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;

View File

@@ -6,9 +6,11 @@ import {
TDisplay,
TDisplayCreateInput,
TDisplayLegacyCreateInput,
TDisplayLegacyUpdateInput,
TDisplayUpdateInput,
ZDisplayCreateInput,
ZDisplayLegacyCreateInput,
ZDisplayLegacyUpdateInput,
ZDisplayUpdateInput,
} from "@formbricks/types/displays";
import { ZId } from "@formbricks/types/environment";
@@ -16,10 +18,11 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getPersonByUserId } from "../person/service";
import { createPerson, getPersonByUserId } from "../person/service";
import { validateInputs } from "../utils/validate";
import { displayCache } from "./cache";
import { formatDisplaysDateFields } from "./util";
import { TPerson } from "@formbricks/types/people";
const selectDisplay = {
id: true,
@@ -30,11 +33,91 @@ const selectDisplay = {
personId: true,
};
export const getDisplay = async (displayId: string): Promise<TDisplay | null> =>
await unstable_cache(
async () => {
validateInputs([displayId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: displayId,
},
select: selectDisplay,
});
return responsePrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDisplay-${displayId}`],
{
tags: [displayCache.tag.byId(displayId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const updateDisplay = async (
displayId: string,
displayInput: Partial<TDisplayUpdateInput>
displayInput: TDisplayUpdateInput
): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayUpdateInput.partial()]);
let person: TPerson | null = null;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
if (!person) {
throw new ResourceNotFoundError("Person", displayInput.userId);
}
}
try {
const data = {
...(person?.id && {
person: {
connect: {
id: person.id,
},
},
}),
...(displayInput.responseId && {
responseId: displayInput.responseId,
}),
};
const display = await prisma.display.update({
where: {
id: displayId,
},
data,
select: selectDisplay,
});
displayCache.revalidate({
id: display.id,
surveyId: display.surveyId,
});
return display;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateDisplayLegacy = async (
displayId: string,
displayInput: TDisplayLegacyUpdateInput
): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayLegacyUpdateInput]);
try {
const data = {
...(displayInput.personId && {
@@ -74,16 +157,22 @@ export const updateDisplay = async (
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayCreateInput]);
const { environmentId, userId, surveyId } = displayInput;
try {
let person;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
if (userId) {
person = await getPersonByUserId(environmentId, userId);
if (!person) {
person = await createPerson(environmentId, userId);
}
}
const display = await prisma.display.create({
data: {
survey: {
connect: {
id: displayInput.surveyId,
id: surveyId,
},
},
@@ -152,7 +241,7 @@ export const createDisplayLegacy = async (displayInput: TDisplayLegacyCreateInpu
}
};
export const markDisplayResponded = async (displayId: string): Promise<TDisplay> => {
export const markDisplayRespondedLegacy = async (displayId: string): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {

View File

@@ -1,7 +1,7 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { config } from 'dotenv';
config({ path: '../../.env' });
/* import { config } from 'dotenv';
config({ path: '../../.env' }); */
export const env = createEnv({
/*

View File

@@ -22,14 +22,13 @@ import { validateInputs } from "../utils/validate";
import { environmentCache } from "./cache";
import { formatEnvironmentDateFields } from "./util";
export const getEnvironment = (environmentId: string) =>
export const getEnvironment = (environmentId: string): Promise<TEnvironment | null> =>
unstable_cache(
async (): Promise<TEnvironment> => {
async () => {
validateInputs([environmentId, ZId]);
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findUnique({
return await prisma.environment.findUnique({
where: {
id: environmentId,
},
@@ -42,16 +41,6 @@ export const getEnvironment = (environmentId: string) =>
throw error;
}
try {
const environment = ZEnvironment.parse(environmentPrisma);
return environment;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environment failed");
}
},
[`getEnvironment-${environmentId}`],
{

View File

@@ -12,29 +12,30 @@
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
},
"dependencies": {
"@formbricks/api": "*",
"@aws-sdk/client-s3": "3.433.0",
"@aws-sdk/s3-request-presigner": "3.433.0",
"@aws-sdk/s3-presigned-post": "^3.454.0",
"@aws-sdk/client-s3": "3.454.0",
"@aws-sdk/s3-request-presigner": "3.454.0",
"@t3-oss/env-nextjs": "^0.7.1",
"mime": "3.0.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
"@formbricks/types": "*",
"@paralleldrive/cuid2": "^2.2.2",
"aws-crt": "^1.18.1",
"aws-crt": "^1.19.0",
"date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"markdown-it": "^13.0.2",
"nanoid": "^5.0.2",
"next-auth": "^4.23.2",
"nodemailer": "^6.9.6",
"posthog-node": "^3.1.2",
"nanoid": "^5.0.3",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.7",
"posthog-node": "^3.1.3",
"server-only": "^0.0.1",
"tailwind-merge": "^1.14.0"
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@types/jsonwebtoken": "^9.0.3",
"@types/mime": "3.0.3",
"@types/jsonwebtoken": "^9.0.5",
"@types/mime": "3.0.4",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -25,6 +25,6 @@ export const canUserAccessPerson = async (userId: string, personId: string): Pro
[`canUserAccessPerson-${userId}-people-${personId}`],
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [personCache.tag.byId(personId), personCache.tag.byUserId(userId)],
tags: [personCache.tag.byId(personId)],
}
)();

View File

@@ -14,11 +14,8 @@ export const personCache = {
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-people`;
},
byUserId(userId: string): string {
return `users-${userId}-people`;
},
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
return `environments-${environmentId}-users-${userId}-people`;
return `environments-${environmentId}-personByUserId-${userId}`;
},
},
revalidate({ id, environmentId, userId }: RevalidateProps): void {
@@ -26,16 +23,12 @@ export const personCache = {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
if (environmentId && userId) {
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -10,6 +10,7 @@ import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
import { personCache } from "./cache";
import { createAttributeClass, getAttributeClassByName } from "../attributeClass/service";
export const selectPerson = {
id: true,
@@ -180,12 +181,29 @@ export const createPerson = async (environmentId: string, userId: string): Promi
personCache.revalidate({
id: transformedPerson.id,
environmentId: transformedPerson.environmentId,
environmentId,
userId,
});
return transformedPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// If the person already exists, return it
if (error.code === "P2002") {
// HOTFIX to handle formbricks-js failing because of caching issue
// Handle the case where the person record already exists
const existingPerson = await prisma.person.findFirst({
where: {
environmentId,
userId,
},
select: selectPerson,
});
if (existingPerson) {
return transformPrismaPerson(existingPerson);
}
}
throw new DatabaseError(error.message);
}
@@ -224,20 +242,62 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
validateInputs([personId, ZId], [personInput, ZPersonUpdateInput]);
try {
const person = await prisma.person.update({
where: {
id: personId,
},
data: personInput,
select: selectPerson,
const person = await getPerson(personId);
if (!person) {
throw new Error(`Person ${personId} not found`);
}
// Process each attribute
const attributeUpdates = Object.entries(personInput.attributes).map(async ([attributeName, value]) => {
let attributeClass = await getAttributeClassByName(person.environmentId, attributeName);
// Create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(person.environmentId, attributeName, "code");
}
// Now perform the upsert for the attribute with the found or created attributeClassId
await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
attributeClassId: attributeClass!.id,
personId,
},
},
update: {
value: value.toString(),
},
create: {
attributeClass: {
connect: {
id: attributeClass!.id,
},
},
person: {
connect: {
id: personId,
},
},
value: value.toString(),
},
});
});
// Execute all attribute updates
await Promise.all(attributeUpdates);
personCache.revalidate({
id: personId,
environmentId: person.environmentId,
});
return transformPrismaPerson(person);
const updatedPerson = await getPerson(personId);
if (!updatedPerson) {
throw new Error(`Person ${personId} not found`);
}
return updatedPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -247,10 +307,10 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
}
};
export const getPersonByUserId = async (userId: string, environmentId: string): Promise<TPerson | null> => {
const personPrisma = await unstable_cache(
export const getPersonByUserId = async (environmentId: string, userId: string): Promise<TPerson | null> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
validateInputs([environmentId, ZId], [userId, ZString]);
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
@@ -262,7 +322,7 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
});
if (personWithUserId) {
return personWithUserId;
return transformPrismaPerson(personWithUserId);
}
// Check if a person with the userId attribute exists
@@ -304,57 +364,13 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
personCache.revalidate({
id: personWithUserIdAttribute.id,
environmentId: personWithUserIdAttribute.environmentId,
environmentId,
userId,
});
return personWithUserIdAttribute;
return transformPrismaPerson(personWithUserIdAttribute);
},
[`getPersonByUserId-${userId}-${environmentId}`],
{
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!personPrisma) {
return null;
}
return transformPrismaPerson(personPrisma);
};
export const getOrCreatePersonByUserId = async (userId: string, environmentId: string): Promise<TPerson> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
let person = await getPersonByUserId(userId, environmentId);
if (person) {
return person;
}
// create a new person
const personPrisma = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
userId,
},
select: selectPerson,
});
personCache.revalidate({
id: personPrisma.id,
environmentId: personPrisma.environmentId,
userId,
});
return transformPrismaPerson(personPrisma);
},
[`getOrCreatePersonByUserId-${userId}-${environmentId}`],
[`getPersonByUserId-${environmentId}-${userId}`],
{
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,

View File

@@ -1,5 +1,5 @@
import { TPerson } from "@formbricks/types/people";
export const getPersonIdentifier = (person: TPerson): string | number | null => {
return person?.attributes?.userId || person?.attributes?.email || person?.id || null;
return person?.userId || person?.attributes?.userId || person?.attributes?.email || person?.id || null;
};

View File

@@ -8,8 +8,10 @@ import { TPerson } from "@formbricks/types/people";
import {
TResponse,
TResponseInput,
TResponseLegacyInput,
TResponseUpdateInput,
ZResponseInput,
ZResponseLegacyInput,
ZResponseUpdateInput,
} from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -17,7 +19,7 @@ import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { deleteDisplayByResponseId } from "../display/service";
import { getPerson, transformPrismaPerson } from "../person/service";
import { createPerson, getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
import { formatResponseDateFields } from "../response/util";
import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
@@ -195,6 +197,72 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, surveyId, finished, data, meta, singleUseId } = responseInput;
try {
let person: TPerson | null = null;
if (userId) {
person = await getPersonByUserId(environmentId, userId);
if (!person) {
// create person if it does not exist
person = await createPerson(environmentId, userId);
}
}
const responsePrisma = await prisma.response.create({
data: {
survey: {
connect: {
id: surveyId,
},
},
finished: finished,
data: data,
...(person?.id && {
person: {
connect: {
id: person.id,
},
},
personAttributes: person?.attributes,
}),
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
},
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
responseCache.revalidate({
id: response.id,
personId: response.person?.id,
surveyId: response.surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const createResponseLegacy = async (responseInput: TResponseLegacyInput): Promise<TResponse> => {
validateInputs([responseInput, ZResponseLegacyInput]);
captureTelemetry("response created");
try {
let person: TPerson | null = null;

View File

@@ -78,7 +78,7 @@ export class ResponseQueue {
const response = await this.api.client.response.create({
...responseUpdate,
surveyId: this.surveyState.surveyId,
personId: this.surveyState.personId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
});
if (!response.ok) {

View File

@@ -1,28 +1,27 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSurvey, TSurveyAttributeFilter, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { TActionClass } from "@formbricks/types/actionClasses";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { getActionClasses } from "../actionClass/service";
import { getAttributeClasses } from "../attributeClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { formatSurveyDateFields } from "./util";
import { surveyCache } from "./cache";
import { displayCache } from "../display/cache";
import { productCache } from "../product/cache";
import { TPerson } from "@formbricks/types/people";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { getAttributeClasses } from "../attributeClass/service";
import { getProductByEnvironmentId } from "../product/service";
import { getDisplaysByPersonId } from "../display/service";
import { diffInDays } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { formatSurveyDateFields } from "./util";
export const selectSurvey = {
id: true,
@@ -606,12 +605,87 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
return newSurvey;
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<TSurvey[]> =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null)
.length === 0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
},
[`getSyncSurveysCached-${environmentId}`],
[`getSyncSurveys-${environmentId}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
@@ -621,86 +695,3 @@ export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -3,7 +3,7 @@ import { TResponseUpdate } from "@formbricks/types/responses";
export class SurveyState {
responseId: string | null = null;
displayId: string | null = null;
personId: string | null = null;
userId: string | null = null;
surveyId: string;
responseAcc: TResponseUpdate = { finished: false, data: {} };
singleUseId: string | null;
@@ -12,10 +12,10 @@ export class SurveyState {
surveyId: string,
singleUseId?: string | null,
responseId?: string | null,
personId?: string | null
userId?: string | null
) {
this.surveyId = surveyId;
this.personId = personId ?? null;
this.userId = userId ?? null;
this.singleUseId = singleUseId ?? null;
this.responseId = responseId ?? null;
}
@@ -36,7 +36,7 @@ export class SurveyState {
this.surveyId,
this.singleUseId ?? undefined,
this.responseId ?? undefined,
this.personId ?? undefined
this.userId ?? undefined
);
copyInstance.responseId = this.responseId;
copyInstance.responseAcc = this.responseAcc;
@@ -60,11 +60,11 @@ export class SurveyState {
}
/**
* Update the person ID
* @param id - The person ID
* Update the user ID
* @param id - The user ID
*/
updatePersonId(id: string) {
this.personId = id;
updateUserId(id: string) {
this.userId = id;
}
/**

View File

@@ -3,7 +3,7 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"baseUrl": "packages/lib",
"paths": {
"@/*": ["../../apps/web/*"],
"@prisma/client/*": ["@formbricks/database/client/*"]

View File

@@ -8,7 +8,7 @@ export const validateInputs = (...pairs: ValidationPair[]): void => {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
console.error(`Validation failed for ${schema}: ${inputValidation.error.message}`);
console.error(`Validation failed for ${JSON.stringify(schema)}: ${inputValidation.error.message}`);
throw new ValidationError("Validation failed");
}
}

View File

@@ -1,53 +0,0 @@
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
root: true,
env: {
browser: true,
es6: true,
node: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
project: ["./tsconfig.json"],
sourceType: "module",
extraFileExtensions: [".json"],
tsconfigRootDir: __dirname,
},
ignorePatterns: [".eslintrc.js", "**/*.js", "**/node_modules/**", "**/dist/**"],
overrides: [
{
files: ["package.json"],
plugins: ["eslint-plugin-n8n-nodes-base"],
extends: ["plugin:n8n-nodes-base/community"],
rules: {
"n8n-nodes-base/community-package-json-name-still-default": "off",
},
},
{
files: ["./credentials/**/*.ts"],
plugins: ["eslint-plugin-n8n-nodes-base"],
extends: ["plugin:n8n-nodes-base/credentials"],
rules: {
"n8n-nodes-base/cred-class-field-documentation-url-missing": "off",
"n8n-nodes-base/cred-class-field-documentation-url-miscased": "off",
},
},
{
files: ["./nodes/**/*.ts"],
plugins: ["eslint-plugin-n8n-nodes-base"],
extends: ["plugin:n8n-nodes-base/nodes"],
rules: {
"n8n-nodes-base/node-execute-block-missing-continue-on-fail": "off",
"n8n-nodes-base/node-resource-description-filename-against-convention": "off",
"n8n-nodes-base/node-param-fixed-collection-type-unsorted-items": "off",
},
},
],
};

View File

@@ -1,16 +0,0 @@
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: "./.eslintrc.js",
overrides: [
{
files: ["package.json"],
plugins: ["eslint-plugin-n8n-nodes-base"],
rules: {
"n8n-nodes-base/community-package-json-name-still-default": "error",
},
},
],
};

View File

@@ -1,8 +0,0 @@
node_modules
.DS_Store
.tmp
tmp
dist
npm-debug.log*
yarn.lock
.vscode/launch.json

View File

@@ -1,2 +0,0 @@
.DS_Store
*.tsbuildinfo

View File

@@ -1,7 +0,0 @@
# @formbricks/n8n-nodes-formbricks
## 0.2.0
### Minor Changes
- aa79c4c3: Add new n8n Integration for Formbricks; huge thanks to @PratikAwaik

View File

@@ -1,19 +0,0 @@
Copyright 2022 n8n
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,39 +0,0 @@
# n8n-nodes-formbricks
This is an n8n community node. It lets you use Formbricks in your n8n workflows.
Formbricks is an open-source experience management solution that lets you understand what customers think & feel about your product by running highly targeted surveys inside your product.
[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform.
[Installation](#installation)
[Operations](#operations)
[Credentials](#credentials) <!-- delete if no auth needed -->
[Compatibility](#compatibility)
[Usage](#usage) <!-- delete if not using this section -->
[Resources](#resources)
## Installation
Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community nodes documentation.
## Operations
Run workflows on new responses you receive for your surveys.
## Credentials
You can use this integration in Formbricks Cloud as well as self-hosted instances of Formbricks. You only need a Formbricks API Key for this. Please check out the [Formbricks Docs]() for more information.
## Compatibility
This package was developed & tested with n8n > 1.4.0.
## Usage
Please check out the [Formbricks Docs](https://formbricks.com/docs/api/api-key-setup) for more information on how to use the integration.
## Resources
- [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/)
- [Formbricks Docs](https://formbricks.com/docs/integrations/n8n)

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