feat: Make formbricks-js ready for public websites (#1470)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Anshuman Pandey
2023-11-12 14:42:58 +05:30
committed by GitHub
parent ac8cf987d3
commit 53ef8771f3
105 changed files with 2262 additions and 1508 deletions

View File

@@ -60,11 +60,10 @@ Community managers will follow these Community Impact Guidelines in determining
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).

View File

@@ -6,7 +6,7 @@ Discover a myriad of ways to leave your mark on Formbricks — whether it's by s
## 🐛 Issue Hunters
Did you stumble upon a bug? Encountered a hiccup in deployment? Perhaps you have some user feedback to share? Your quickest route to help us out is by [raising an issue](https://github.com/formbricks/formbricks/issues/new/choose). We're on standby to respond swiftly.
Did you stumble upon a bug? Encountered a hiccup in deployment? Perhaps you have some user feedback to share? Your quickest route to help us out is by [raising an issue](https://github.com/formbricks/formbricks/issues/new/choose). We're on standby to respond swiftly.
## 💡 Feature Architects
@@ -20,13 +20,12 @@ Ready to dive into the code and make a real impact? Here's your path:
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://formbricks.com/docs/contributing/gitpod)
2. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
3. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? Our [Discord server](https://formbricks.com/discord) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features
If you spot a feature that isn't part of our official plan but could propel Formbricks forward, don't hesitate. Raise it as an enhancement issue, and let us know you're ready to take the lead. We'll be quick to respond.

View File

@@ -1,38 +1,8 @@
import formbricks from "@formbricks/js";
import type { AppProps } from "next/app";
import Head from "next/head";
import { useRouter } from "next/router";
import { useEffect } from "react";
import "../styles/globals.css";
declare const window: any;
if (typeof window !== "undefined") {
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
debug: true,
});
window.formbricks = formbricks;
}
}
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
useEffect(() => {
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}
}, []);
return (
<>
<Head>

View File

@@ -2,9 +2,13 @@ import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
import { useRouter } from "next/router";
declare const window: any;
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
useEffect(() => {
if (darkMode) {
@@ -14,6 +18,30 @@ export default function AppPage({}) {
}
}, [darkMode]);
useEffect(() => {
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const isUserId = window.location.href.includes("userId=true");
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
debug: true,
});
window.formbricks = formbricks;
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}
});
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
@@ -204,25 +232,37 @@ export default function AppPage({}) {
</div>
</div>
<div className="p-6">
<div>
<button
onClick={() => {
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
}}
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">
Set User ID
</button>
</div>
{router.query.userId === "true" ? (
<div>
<button
onClick={() => {
window.location.href = "/app";
}}
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">
Deactivate User Identification
</button>
</div>
) : (
<div>
<button
onClick={() => {
window.location.href = "/app?userId=true";
}}
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">
Activate User Identification
</button>
</div>
)}
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets an external{" "}
This button activates/deactivates{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
target="_blank"
className="underline dark:text-blue-500">
user ID
user identification
</a>{" "}
to &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
with the userId &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
</p>
</div>
</div>

View File

@@ -8,7 +8,10 @@ export const metadata = {
# Code Actions
Actions can also be set in the code base. You can fire an action using `formbricks.track()`
Actions can also be set in the codebase to trigger surveys. Please add the code action first in the Formbricks web interface to be able to configure your surveys to use this action.
After that you can fire an action using `formbricks.track()`
<Col>
<CodeGroup title="Track an action">

View File

@@ -10,23 +10,29 @@ export const metadata = {
At Formbricks, we value user privacy. By default, Formbricks doesn't collect or store any personal information from your users. However, we understand that it can be helpful for you to know which user submitted the feedback and also functionality like recontacting users and controlling the waiting period between surveys requires identifying the users. That's why we provide a way for you to share existing user data from your app, so you can view it in our dashboard.
Once the Formbricks widget is loaded on your web app, our SDK exposes methods for identifying user attributes. Let's set it up!
If you would like to use the User Identification feature of Formbricks, target surveys to specific user segments and see more information about the user who responded to a survey, you can identify users by setting a User ID, email, and custom attributes. This guide will walk you through how to do that.
## Setting User ID
You can use the `setUserId` function to identify a user with any string. It's best to use the default identifier you use in your app (e.g. unique id from database) but you can also anonymize these as long as they are unique for every user. This function can be called multiple times with the same value safely and stores the identifier in local storage. We recommend you set the User ID whenever the user logs in to your website, as well as after the installation snippet (if the user is already logged in).
To enable the User identification feature you need to set the `userId` in the init() call of Formbricks. Only when the `userId` is set the person will be visible in the Formbricks dashboard. The `userId` can be any string and it's best to use the default identifier you use in your app (e.g. unique id from database or the email address if it's unique) but you can also anonymize these as long as they are unique for every user.
<Col>
<CodeGroup title="Setting User ID">
```javascript
formbricks.setUserId("USER_ID");
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
userID: "<user_id>",
});
```
</CodeGroup>
</Col>
## Setting User Email
You can use the setEmail function to set the user's email:
The `userId` is the main identifier used in Formbricks and user identification is only enabled when it is set. In addition to the userId you can also set attributes that describes the user better. The email address can be set using the setEmail function:
<Col>
<CodeGroup title="Setting Email">
@@ -39,11 +45,12 @@ formbricks.setEmail("user@example.com");
### Setting Custom User Attributes
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.):
<Col>
<CodeGroup title="Setting Custom Attributes">
```javascript
formbricks.setAttribute("attribute_key", "attribute_value");
formbricks.setAttribute("Plan", "free");
```
</CodeGroup>
@@ -51,6 +58,7 @@ formbricks.setAttribute("attribute_key", "attribute_value");
### Logging Out Users
When a user logs out of your webpage, make sure to log them out of Formbricks as well. This will prevent new activity from being associated with an incorrect user. Use the logout function:
<Col>
<CodeGroup title="Logging out User">
@@ -59,4 +67,4 @@ formbricks.logout();
```
</CodeGroup>
</Col>
</Col>

View File

@@ -4,7 +4,8 @@ import DemoApp from "./demoapp.webp";
export const metadata = {
title: "Formbricks Demo App Guide: Play around with Formbricks",
description: "To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions",
description:
"To test in-app surveys, trigger actions and set attributes, you can use the Demo App. This guide provides hands-on examples of sending both code and no-code actions",
};
#### Contributing
@@ -13,13 +14,14 @@ export const metadata = {
To play around with the in-app [User Actions](/docs/actions/why), you can use the Demo App. It's a simple React app that you can run locally and use to trigger actions and set [Attributes](/docs/attributes/why).
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="rounded-lg max-w-full sm:max-w-3xl" />
<Image src={DemoApp} alt="Demo App Preview" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
## Functionality
### Code Action
This button sends a <a href="/docs/actions/code">Code Action</a> to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
<Col>
<CodeGroup title="Track Code action">
@@ -32,6 +34,7 @@ formbricks.track("Code Action");
### No Code Action
This button sends a <a href="/docs/actions/no-code">No Code Action</a> as long as you created it beforehand in the Formbricks App. For it to work, you need to add the No Code Action within Formbricks.
<Col>
<CodeGroup title="Track No-Code action">
@@ -44,6 +47,7 @@ This button sends a <a href="/docs/actions/no-code">No Code Action</a> as long a
### Set Plan to "Free"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Free'. If the attribute does not exist, it creates it.
<Col>
<CodeGroup title="Set Plan to Free">
@@ -56,6 +60,7 @@ formbricks.setAttribute("Plan", "Free");
### Set Plan to "Paid"
This button sets the <a href="/docs/attributes/custom-attributes">attribute</a> 'Plan' to 'Paid'. If the attribute does not exist, it creates it.
<Col>
<CodeGroup title="Set Plan to Paid">
@@ -68,6 +73,7 @@ formbricks.setAttribute("Plan", "Paid");
### Set Email
This button sets the <a href="/docs/attributes/identify-users">user email</a> 'test@web.com'
<Col>
<CodeGroup title="Set Email">
@@ -79,13 +85,14 @@ formbricks.setEmail("test@web.com");
</Col>
### Set UserId
This button sets an external <a href="/docs/attributes/identify-users">user ID</a> to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
This button sets an external <a href="/docs/attributes/identify-users">user ID</a> in the Formbricks init call to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
<Col>
<CodeGroup title="Set User ID">
```tsx
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
userId: "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
```
</CodeGroup>
</Col>
</Col>

View File

@@ -45,7 +45,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and thats abou
```html {{ title: 'index.html' }}
<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.4/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "<your-environment-id>", apiHost: "<api-host>"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "<your-environment-id>", apiHost: "<api-host>"})},500)}();
</script>
<!-- END Formbricks Surveys -->
```

View File

@@ -105,6 +105,8 @@ In the manual below, this code snippet contains all the information you need:
className="max-w-full rounded-lg sm:max-w-3xl"
/>
If you like to use the user identification feature, please follow the [user identification guide](/docs/attributes/identify-users).
## Load Formbricks widget in your app
In a local instance of your app, you'll embed the Formbricks Widget. Dependent on your frontend tech, the setup differs a bit:

View File

@@ -4,13 +4,13 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
const DummyUI: React.FC = () => {
const eventClasses = [
const actionClasses = [
{ id: "1", name: "View Dashboard" },
{ id: "2", name: "Upgrade to Pro" },
{ id: "3", name: "Cancel Plan" },
];
const [triggers, setTriggers] = useState<string[]>([eventClasses[0].id]);
const [triggers, setTriggers] = useState<string[]>([actionClasses[0].id]);
const setTriggerEvent = (index: number, eventClassId: string) => {
setTriggers((prevTriggers) =>
@@ -19,7 +19,7 @@ const DummyUI: React.FC = () => {
};
const addTriggerEvent = () => {
setTriggers((prevTriggers) => [...prevTriggers, eventClasses[0].id]);
setTriggers((prevTriggers) => [...prevTriggers, actionClasses[0].id]);
};
const removeTriggerEvent = (index: number) => {
@@ -41,12 +41,12 @@ const DummyUI: React.FC = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventClasses.map((eventClass) => (
{actionClasses.map((actionClass) => (
<SelectItem
key={eventClass.id}
key={actionClass.id}
className="xs:text-base px-0.5 py-1 text-xs text-slate-800 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
value={eventClass.id}>
{eventClass.name}
value={actionClass.id}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>

View File

@@ -60,7 +60,7 @@ if (typeof window !== "undefined") {
</>
) : activeTab === "html" ? (
<CodeBlock>{`<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.4/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init("claDadXk29dak92dK9","https://app.formbricks.com")},500)}();
</script>`}</CodeBlock>
) : null}
</div>

View File

@@ -129,8 +129,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
{
name: "Revert",
description:
"The open-source unified API to build B2B integrations remarkably fast",
description: "The open-source unified API to build B2B integrations remarkably fast",
href: "https://revert.dev",
},
{

View File

@@ -15,8 +15,8 @@ export default function FormbricksClient({ session }) {
formbricks.init({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
userId: session.user.id,
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);
}
}, [session]);

View File

@@ -3,7 +3,7 @@
import { Button } from "@formbricks/ui/Button";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import AddNoCodeActionModal from "./AddNoCodeActionModal";
import AddNoCodeActionModal from "./AddActionModal";
import ActionDetailModal from "./ActionDetailModal";
import { TActionClass } from "@formbricks/types/actionClasses";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";

View File

@@ -0,0 +1,270 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { TabBar } from "@formbricks/ui/TabBar";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Terminal } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
isViewer: boolean;
}
function isValidCssSelector(selector?: string) {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
}
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
isViewer,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const [type, setType] = useState("noCode");
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (type === "noCode") {
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");
}
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
throw new Error("Please select a rule for page URL");
}
}
const updatedAction: TActionClassInput = {
name: data.name,
description: data.description,
environmentId,
type,
} as TActionClassInput;
if (type === "noCode") {
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
updatedAction.noCodeConfig = filteredNoCodeConfig;
}
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = (open: boolean) => {
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(open);
};
return (
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
<div className="text-sm text-slate-500">
Track a user action to display surveys or create user segment.
</div>
</div>
</div>
</div>
</div>
<TabBar
tabs={[
{ id: "noCode", label: "No Code" },
{ id: "code", label: "Code" },
]}
activeId={type}
setActiveId={setType}
/>
{type === "noCode" ? (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>What did your user do?</Label>
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
) : (
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>Identifier</Label>
<Input placeholder="E.g. clicked-download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<hr />
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>How do Code Actions work?</AlertTitle>
<AlertDescription>
You can track code action anywhere in your app using{" "}
<span className="rounded bg-gray-100 px-2 py-1 text-xs">
formbricks.track(&quot;{watch("name")}&quot;)
</span>{" "}
in your code. Read more in our{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
)}
</div>
</Modal>
);
}

View File

@@ -1,207 +0,0 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/CssSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/InnerHtmlSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/PageUrlSelector";
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "../lib/testURLmatch";
interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
isViewer: boolean;
}
function isValidCssSelector(selector?: string) {
if (!selector || selector.length === 0) {
return false;
}
const element = document.createElement("div");
try {
element.querySelector(selector);
} catch (err) {
return false;
}
return true;
}
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
isViewer,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
const [isInnerHtml, setIsInnerText] = useState(false);
const [isCreatingAction, setIsCreatingAction] = useState(false);
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
}
if (isInnerHtml && innerHtml?.value) {
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
}
if (isCssSelector && cssSelector?.value) {
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
}
return filteredNoCodeConfig;
};
const handleMatchClick = () => {
const match = testURLmatch(
testUrl,
watch("noCodeConfig.[pageUrl].value"),
watch("noCodeConfig.[pageUrl].rule")
);
setIsMatch(match);
if (match === "yes") toast.success("Your survey would be shown on this URL.");
if (match === "no") toast.error("Your survey would not be shown.");
};
const submitEventClass = async (data: Partial<TActionClassInput>): Promise<void> => {
const { noCodeConfig } = data;
try {
if (isViewer) {
throw new Error("You are not authorised to perform this action.");
}
setIsCreatingAction(true);
if (data.name === "") throw new Error("Please give your action a name");
if (!isPageUrl && !isCssSelector && !isInnerHtml)
throw new Error("Please select at least one selector");
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value)) {
throw new Error("Please enter a valid CSS Selector");
}
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined) {
throw new Error("Please select a rule for page URL");
}
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
const updatedData: TActionClassInput = {
...data,
environmentId,
noCodeConfig: filteredNoCodeConfig,
type: "noCode",
} as TActionClassInput;
const newActionClass: TActionClass = await createActionClassAction(updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setIsCreatingAction(false);
}
};
const resetAllStates = (open: boolean) => {
setIsCssSelector(false);
setIsPageUrl(false);
setIsInnerText(false);
setTestUrl("");
setIsMatch("");
reset();
setOpen(open);
};
return (
<Modal open={open} setOpen={() => resetAllStates(false)} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
<div className="text-sm text-slate-500">
Track a user action to display surveys or create user segment.
</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitEventClass)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<Label>What did your user do?</Label>
<Input placeholder="E.g. Clicked Download" {...register("name", { required: true })} />
</div>
<div className="col-span-1">
<Label>Description</Label>
<Input placeholder="User clicked Download Button " {...register("description")} />
</div>
</div>
<div>
<Label>Select By</Label>
</div>
<CssSelector
isCssSelector={isCssSelector}
setIsCssSelector={setIsCssSelector}
register={register}
/>
<PageUrlSelector
isPageUrl={isPageUrl}
setIsPageUrl={setIsPageUrl}
register={register}
control={control}
testUrl={testUrl}
setTestUrl={setTestUrl}
isMatch={isMatch}
setIsMatch={setIsMatch}
handleMatchClick={handleMatchClick}
/>
<InnerHtmlSelector
isInnerHtml={isInnerHtml}
setIsInnerHtml={setIsInnerText}
register={register}
/>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button type="button" variant="minimal" onClick={() => resetAllStates(false)}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Track Action
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -91,7 +91,7 @@ export async function copyToOtherEnvironmentAction(
include: {
triggers: {
include: {
eventClass: true,
actionClass: true,
},
},
attributeFilters: {
@@ -109,9 +109,9 @@ export async function copyToOtherEnvironmentAction(
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.eventClass.findFirst({
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.eventClass.name,
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
@@ -119,18 +119,18 @@ export async function copyToOtherEnvironmentAction(
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.eventClass.create({
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.eventClass.name,
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.eventClass.description,
type: trigger.eventClass.type,
noCodeConfig: trigger.eventClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig))
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
@@ -183,8 +183,8 @@ export async function copyToOtherEnvironmentAction(
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((eventClassId) => ({
eventClassId: eventClassId,
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {

View File

@@ -1,7 +1,5 @@
import { getLatestActionByEnvironmentId } from "@formbricks/lib/action/service";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { timeSince } from "@formbricks/lib/time";
import { ArrowDownIcon, CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { ArrowDownIcon, CheckIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
@@ -11,14 +9,7 @@ interface WidgetStatusIndicatorProps {
}
export default async function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
const [environment, latestAction] = await Promise.all([
getEnvironment(environmentId),
getLatestActionByEnvironmentId(environmentId),
]);
if (!environment?.widgetSetupCompleted && latestAction) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
const [environment] = await Promise.all([getEnvironment(environmentId)]);
const stati = {
notImplemented: {
@@ -27,26 +18,18 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
title: "Connect Formbricks to your app.",
subtitle: "You have not yet connected Formbricks to your app. Follow setup guide.",
},
running: { icon: CheckIcon, color: "green", title: "Receiving data.", subtitle: "Last action received:" },
issue: {
icon: ExclamationTriangleIcon,
color: "amber",
title: "There might be an issue.",
subtitle: "Last action received:",
running: {
icon: CheckIcon,
color: "green",
title: "Receiving data.",
subtitle: "You have successfully connected Formbricks to your app.",
},
};
let status: "notImplemented" | "running" | "issue";
if (latestAction) {
const currentTime = new Date();
const timeDifference = currentTime.getTime() - new Date(latestAction.createdAt).getTime();
if (timeDifference <= 24 * 60 * 60 * 1000) {
status = "running";
} else {
status = "issue";
}
if (environment.widgetSetupCompleted) {
status = "running";
} else {
status = "notImplemented";
}
@@ -59,23 +42,18 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
className={clsx(
"flex flex-col items-center justify-center space-y-2 rounded-lg py-6 text-center",
status === "notImplemented" && "bg-slate-100",
status === "running" && "bg-green-100",
status === "issue" && "bg-amber-100"
status === "running" && "bg-green-100"
)}>
<div
className={clsx(
"h-12 w-12 rounded-full bg-white p-2",
status === "notImplemented" && "text-slate-700",
status === "running" && "text-green-700",
status === "issue" && "text-amber-700"
status === "running" && "text-green-700"
)}>
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="text-sm text-slate-700">
{currentStatus.subtitle}{" "}
{latestAction && <span>{timeSince(latestAction.createdAt.toISOString())}</span>}
</p>
<p className="text-sm text-slate-700">{currentStatus.subtitle}</p>
</div>
);
}
@@ -84,16 +62,12 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
<Link href={`/environments/${environment.id}/settings/setup`}>
<div className="group my-4 flex justify-center">
<div className=" flex rounded-full bg-slate-100 px-2 py-1">
<p className="mr-2 text-sm text-slate-400 group-hover:underline">
{currentStatus.subtitle}{" "}
{latestAction && <span>{timeSince(latestAction.createdAt.toISOString())}</span>}
</p>
<p className="mr-2 text-sm text-slate-400 group-hover:underline">{currentStatus.subtitle}</p>
<div
className={clsx(
"h-5 w-5 rounded-full p-0.5",
status === "notImplemented" && "bg-slate-100 text-slate-700",
status === "running" && "bg-green-100 text-green-700",
status === "issue" && "bg-amber-100 text-amber-700"
status === "running" && "bg-green-100 text-green-700"
)}>
<currentStatus.icon />
</div>

View File

@@ -5,14 +5,13 @@ import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
import { getPerson } from "@formbricks/lib/person/service";
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
import { getSessionCount } from "@formbricks/lib/session/service";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
if (!person) {
throw new Error("No such person found");
}
const numberOfSessions = await getSessionCount(personId);
const responses = await getResponsesByPersonId(personId);
const numberOfResponses = responses?.length || 0;
@@ -35,6 +34,8 @@ export default async function AttributesSection({ personId }: { personId: string
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.userId ? (
<span>{person.attributes.userId}</span>
) : person.userId ? (
<span>{person.userId}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
@@ -56,8 +57,8 @@ export default async function AttributesSection({ personId }: { personId: string
<hr />
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
{/* <dt className="text-sm font-medium text-slate-500">Sessions</dt> */}
{/* <dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd> */}
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>

View File

@@ -12,7 +12,8 @@ export default function Loading() {
{
id: "demoId1",
createdAt: new Date(),
sessionId: "",
// sessionId: "",
personId: "",
properties: {},
actionClass: {
id: "demoId1",
@@ -28,7 +29,8 @@ export default function Loading() {
{
id: "demoId2",
createdAt: new Date(),
sessionId: "",
// sessionId: "",
personId: "",
properties: {},
actionClass: {
id: "demoId2",

View File

@@ -80,7 +80,7 @@ export default async function PeoplePage({
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">
{truncateMiddle(getAttributeValue(person, "userId"), 24)}
{truncateMiddle(getAttributeValue(person, "userId"), 24) || person.userId}
</div>
</div>
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">

View File

@@ -108,7 +108,7 @@ if (typeof window !== "undefined") {
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.1.4/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<p className="text-lg font-semibold text-slate-800">You&apos;re done 🎉</p>

View File

@@ -49,7 +49,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
const options = [
{
id: "web",
name: "Web App",
name: "In-App Survey",
icon: ComputerDesktopIcon,
description: "Embed a survey in your web app to collect responses.",
comingSoon: false,
@@ -65,7 +65,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
},
{
id: "mobile",
name: "Mobile app",
name: "Mobile App Survey",
icon: DevicePhoneMobileIcon,
description: "Survey users inside a mobile app (iOS & Android).",
comingSoon: true,

View File

@@ -1,6 +1,6 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal";
import { cn } from "@formbricks/lib/cn";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TSurvey } from "@formbricks/types/surveys";

View File

@@ -3,12 +3,14 @@
import { cn } from "@formbricks/lib/cn";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TSurvey } from "@formbricks/types/surveys";
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
import { Badge } from "@formbricks/ui/Badge";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { Info } from "lucide-react";
import { useEffect, useState } from "react"; /* */
const filterConditions = [
@@ -99,6 +101,24 @@ export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeCl
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
<div className="mx-6 mb-4 mt-3">
<Alert variant="info">
<Info className="h-4 w-4" />
<AlertTitle>User Identification</AlertTitle>
<AlertDescription>
To target your audience you need to identify your users within your app. You can read more
about how to do this in our{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
className="underline"
target="_blank">
docs
</a>
.
</AlertDescription>
</Alert>
</div>
<div className="mx-6 flex items-center rounded-lg border border-slate-200 p-4 text-slate-800">
<div>
{localSurvey.attributeFilters?.length === 0 ? (

View File

@@ -0,0 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(): Promise<NextResponse> {
return responses.successResponse({}, true);
}

View File

@@ -0,0 +1,116 @@
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

@@ -0,0 +1,133 @@
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

@@ -0,0 +1,74 @@
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 { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleLegacyAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
if (!personId || personId === "legacy") {
return responses.internalServerErrorResponse("setAttribute requires an identified user", true);
}
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleLegacyAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
if (!existingPerson) {
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 state = await getUpdatedState(environmentId, personId);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -0,0 +1,38 @@
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 { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId } = inputValidation.data;
const personWithUserId = await getOrCreatePersonByUserId(userId, environmentId);
const state = await getUpdatedState(environmentId, personWithUserId.id);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
}

View File

@@ -0,0 +1,32 @@
import { responses } from "@/app/lib/api/response";
import { createPerson } from "@formbricks/lib/person/service";
import { NextRequest } from "next/server";
export async function OPTIONS() {
// cors headers
return responses.successResponse({}, true);
}
export async function POST(req: NextRequest) {
// we need to create a new person
// call the createPerson service from here
const { environmentId, userId } = await req.json();
if (!environmentId) {
return responses.badRequestResponse("environmentId is required", { environmentId }, true);
}
if (!userId) {
return responses.badRequestResponse("userId is required", { environmentId }, true);
}
try {
const person = await createPerson(environmentId, userId);
return responses.successResponse({ status: "success", person }, true);
} catch (err) {
return responses.internalServerErrorResponse("Something went wrong", true);
}
}

View File

@@ -1,4 +1,4 @@
import { getSyncSurveysCached } from "@/app/api/v1/js/sync/lib/surveys";
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/sync/lib/surveys";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import {
IS_FORMBRICKS_CLOUD,
@@ -7,33 +7,22 @@ import {
PRICING_USERTARGETING_FREE_MTU,
} from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { createPerson, getPerson } from "@formbricks/lib/person/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { createSession, extendSession, getSession } from "@formbricks/lib/session/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import {
getMonthlyActiveTeamPeopleCount,
getMonthlyTeamResponseCount,
getTeamByEnvironmentId,
} from "@formbricks/lib/team/service";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsState } from "@formbricks/types/js";
import { TJsLegacyState } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TSession } from "@formbricks/types/sessions";
const captureNewSessionTelemetry = async (jsVersion?: string): Promise<void> => {
await captureTelemetry("session created", { jsVersion: jsVersion ?? "unknown" });
};
export const getUpdatedState = async (
environmentId: string,
personId?: string,
sessionId?: string,
jsVersion?: string
): Promise<TJsState> => {
export const getUpdatedState = async (environmentId: string, personId?: string): Promise<TJsLegacyState> => {
let environment: TEnvironment | null;
let person: TPerson;
let session: TSession | null;
let person: TPerson | {};
const session = {};
// check if environment exists
environment = await getEnvironment(environmentId);
@@ -58,64 +47,23 @@ export const getUpdatedState = async (
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
if (isMauLimitReached) {
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
if (!personId || !sessionId) {
if (!personId) {
// don't allow new people or sessions
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);
}
}
}
if (!personId) {
// create a new person
person = await createPerson(environmentId);
// create a new session
session = await createSession(person.id);
person = { id: "legacy" };
} else {
// check if person exists
const existingPerson = await getPerson(personId);
if (!existingPerson) {
// create a new person
person = await createPerson(environmentId);
} else {
if (existingPerson) {
person = existingPerson;
}
}
if (!sessionId) {
// create a new session
session = await createSession(person.id);
} else {
// check validity of person & session
session = await getSession(sessionId);
if (!session) {
// create a new session
session = await createSession(person.id);
captureNewSessionTelemetry(jsVersion);
} else {
// check if session is expired
if (session.expiresAt < new Date()) {
// create a new session
session = await createSession(person.id);
captureNewSessionTelemetry(jsVersion);
} else {
// extend session (if about to expire)
const isSessionAboutToExpire =
new Date(session.expiresAt).getTime() - new Date().getTime() < 1000 * 60 * 10;
if (isSessionAboutToExpire) {
session = await extendSession(sessionId);
}
}
person = { id: "legacy" };
}
}
// check if App Survey limit is reached
@@ -131,9 +79,20 @@ export const getUpdatedState = async (
monthlyResponsesCount >= PRICING_APPSURVEYS_FREE_RESPONSES;
}
const isPerson = Object.keys(person).length > 0;
let surveys;
if (isAppSurveyLimitReached) {
surveys = [];
} else if (isPerson) {
surveys = await getSyncSurveysCached(environmentId, person as TPerson);
} else {
surveys = await getSurveys(environmentId);
surveys = surveys.filter((survey) => survey.type === "web");
}
// get/create rest of the state
const [surveys, noCodeActionClasses, product] = await Promise.all([
!isAppSurveyLimitReached ? getSyncSurveysCached(environmentId, person) : [],
const [noCodeActionClasses, product] = await Promise.all([
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
@@ -143,8 +102,8 @@ export const getUpdatedState = async (
}
// return state
const state: TJsState = {
person: person!,
const state: TJsLegacyState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),

View File

@@ -1,7 +1,7 @@
import { getUpdatedState } from "@/app/api/v1/js/sync/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 { ZJsSyncInput } from "@formbricks/types/js";
import { ZJsSyncLegacyInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -13,7 +13,7 @@ export async function POST(req: Request): Promise<NextResponse> {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsSyncInput.safeParse(jsonInput);
const inputValidation = ZJsSyncLegacyInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -23,9 +23,9 @@ export async function POST(req: Request): Promise<NextResponse> {
);
}
const { environmentId, personId, sessionId } = inputValidation.data;
const { environmentId, personId } = inputValidation.data;
const state = await getUpdatedState(environmentId, personId, sessionId, inputValidation.data.jsVersion);
const state = await getUpdatedState(environmentId, personId);
return responses.successResponse({ ...state }, true);
} catch (error) {

View File

@@ -1,11 +1,11 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { InvalidInputError } from "@formbricks/types/errors";
import { createDisplayLegacy } from "@formbricks/lib/display/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createDisplay } from "@formbricks/lib/display/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TDisplay, ZDisplayCreateInput } from "@formbricks/types/displays";
import { TDisplay, ZDisplayLegacyCreateInput } from "@formbricks/types/displays";
import { InvalidInputError } from "@formbricks/types/errors";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -13,8 +13,11 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function POST(request: Request): Promise<NextResponse> {
const jsonInput: unknown = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse(jsonInput);
const jsonInput = await request.json();
if (jsonInput.personId === "legacy") {
delete jsonInput.personId;
}
const inputValidation = ZDisplayLegacyCreateInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -24,12 +27,14 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
const displayInput = inputValidation.data;
const { surveyId, responseId } = inputValidation.data;
let { personId } = inputValidation.data;
// find environmentId from surveyId
let survey;
try {
survey = await getSurvey(displayInput.surveyId);
survey = await getSurvey(surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -45,7 +50,11 @@ export async function POST(request: Request): Promise<NextResponse> {
// create display
let display: TDisplay;
try {
display = await createDisplay(displayInput);
display = await createDisplayLegacy({
surveyId,
personId,
responseId,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -57,7 +66,7 @@ export async function POST(request: Request): Promise<NextResponse> {
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId, {
surveyId: displayInput.surveyId,
surveyId,
});
} else {
console.warn("Posthog capture not possible. No team owner found");

View File

@@ -0,0 +1,109 @@
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 { 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 { 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();
if (responseInput.personId === "legacy") {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
let survey: TSurvey | null;
try {
survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId);
}
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
const teamDetails = await getTeamDetails(survey.environmentId);
let response: TResponse;
try {
const meta = {
source: responseInput?.meta?.source,
url: responseInput?.meta?.url,
userAgent: {
browser: agent?.browser.name,
device: agent?.device.type,
os: agent?.os.name,
},
};
// check if personId is anonymous
if (responseInput.personId === "anonymous") {
// remove this from the request
responseInput.personId = null;
}
response = await createResponse({
...responseInput,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "response created", teamDetails.teamId, {
surveyId: response.surveyId,
surveyType: survey.type,
});
} else {
console.warn("Posthog capture not possible. No team owner found");
}
return responses.successResponse(response, true);
}

View File

@@ -4,16 +4,25 @@ import { createAction } from "@formbricks/lib/action/service";
import { ZActionInput } from "@formbricks/types/actions";
import { NextResponse } from "next/server";
interface Context {
params: {
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request): Promise<NextResponse> {
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZActionInput.safeParse(jsonInput);
const inputValidation = ZActionInput.safeParse({
...jsonInput,
environmentId: context.params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -23,19 +32,7 @@ export async function POST(req: Request): Promise<NextResponse> {
);
}
const { environmentId, sessionId, name, properties } = inputValidation.data;
// hotfix: don't create action for "Exit Intent (Desktop)", 50% Scroll events
if (["Exit Intent (Desktop)", "50% Scroll"].includes(name)) {
return responses.successResponse({}, true);
}
createAction({
environmentId,
sessionId,
name,
properties,
});
await createAction(inputValidation.data);
return responses.successResponse({}, true);
} catch (error) {

View File

@@ -0,0 +1,40 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { updateDisplay } from "@formbricks/lib/display/service";
import { ZDisplayUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
interface Context {
params: {
displayId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function PUT(request: Request, context: Context): Promise<NextResponse> {
const { displayId } = context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse({
...jsonInput,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
try {
const display = await updateDisplay(displayId, inputValidation.data);
return responses.successResponse(display, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(error.message, true);
}
}

View File

@@ -0,0 +1,65 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createDisplay } from "@formbricks/lib/display/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TDisplay, ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError } from "@formbricks/types/errors";
import { NextResponse } from "next/server";
interface Context {
params: {
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(request: Request, context: Context): Promise<NextResponse> {
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: context.params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
// find teamId & teamOwnerId from environmentId
const teamDetails = await getTeamDetails(inputValidation.data.environmentId);
// create display
let display: TDisplay;
try {
display = await createDisplay(inputValidation.data);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId);
} else {
console.warn("Posthog capture not possible. No team owner found");
}
return responses.successResponse(
{
...display,
createdAt: display.createdAt.toISOString(),
updatedAt: display.updatedAt.toISOString(),
},
true
);
}

View File

@@ -0,0 +1,113 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSyncSurveysCached } 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";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function GET(
_: Request,
{
params,
}: {
params: {
environmentId: string;
userId: string;
};
}
): Promise<NextResponse> {
try {
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse({
environmentId: params.environmentId,
userId: params.userId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
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
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;
// 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})`;
throw new Error(errorMessage);
}
}
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: 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 handle the request: " + error.message, true);
}
}

View File

@@ -0,0 +1,66 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPublicSyncInput } from "@formbricks/types/js";
import { NextRequest, NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function GET(
_: NextRequest,
{ params }: { params: { environmentId: string } }
): Promise<NextResponse> {
try {
// validate using zod
const environmentIdValidation = ZJsPublicSyncInput.safeParse({
environmentId: params.environmentId,
});
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
const { environmentId } = environmentIdValidation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
if (!environment?.widgetSetupCompleted) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveys(environmentId),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
const state: TJsState = {
surveys: surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web"),
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
person: null,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
}
}

View File

@@ -1,4 +1,4 @@
import { getUpdatedState } from "@/app/api/v1/js/sync/lib/sync";
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 { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
@@ -8,13 +8,20 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
params: {
personId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { personId } = params;
const { personId, environmentId } = context.params;
const jsonInput = await req.json();
// validate using zod
@@ -28,7 +35,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
);
}
const { environmentId, sessionId, key, value } = inputValidation.data;
const { key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
@@ -59,7 +66,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
environmentId,
});
const state = await getUpdatedState(environmentId, personId, sessionId);
const state = await getUpdatedState(environmentId, personId);
return responses.successResponse({ ...state }, true);
} catch (error) {

View File

@@ -0,0 +1,32 @@
import { responses } from "@/app/lib/api/response";
import { createPerson } from "@formbricks/lib/person/service";
import { NextRequest } from "next/server";
export async function OPTIONS() {
// cors headers
return responses.successResponse({}, true);
}
export async function POST(req: NextRequest) {
// we need to create a new person
// call the createPerson service from here
const { environmentId, userId } = await req.json();
if (!environmentId) {
return responses.badRequestResponse("environmentId is required", { environmentId }, true);
}
if (!userId) {
return responses.badRequestResponse("userId is required", { environmentId }, true);
}
try {
const person = await createPerson(environmentId, userId);
return responses.successResponse({ status: "success", person }, true);
} catch (err) {
return responses.internalServerErrorResponse("Something went wrong", true);
}
}

View File

@@ -0,0 +1,87 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
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";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function PUT(
request: Request,
{ params }: { params: { responseId: string } }
): Promise<NextResponse> {
const { responseId } = params;
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
// update response
let response;
try {
response = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response,
});
if (response.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: response,
});
}
return responses.successResponse(response, true);
}

View File

@@ -9,7 +9,6 @@ import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
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);
@@ -28,13 +27,10 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
let survey: TSurvey | null;
let survey;
try {
survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId);
}
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -58,6 +54,12 @@ 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,
meta,

View File

@@ -11,7 +11,15 @@ import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { validateLocalSignedUrl } from "@formbricks/lib/crypto";
export async function POST(req: NextRequest): Promise<NextResponse> {
interface Context {
params: {
environmentId: string;
};
}
export async function POST(req: NextRequest, context: Context): Promise<NextResponse> {
const environmentId = context.params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const headersList = headers();
@@ -47,16 +55,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return responses.unauthorizedResponse();
}
const survey = await getSurvey(surveyId);
const [survey, team] = await Promise.all([getSurvey(surveyId), getTeamByEnvironmentId(environmentId)]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
const { environmentId } = survey;
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
return responses.notFoundResponse("TeamByEnvironmentId", environmentId);
}

View File

@@ -4,13 +4,21 @@ import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { NextRequest, NextResponse } from "next/server";
import uploadPrivateFile from "./lib/uploadPrivateFile";
interface Context {
params: {
environmentId: string;
};
}
// api endpoint for uploading private files
// uploaded files will be private, only the user who has access to the environment can access the file
// uploading private files requires no authentication
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export async function POST(req: NextRequest): Promise<NextResponse> {
export async function POST(req: NextRequest, context: Context): Promise<NextResponse> {
const environmentId = context.params.environmentId;
const { fileName, fileType, surveyId } = await req.json();
if (!surveyId) {
@@ -25,16 +33,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return responses.badRequestResponse("contentType is required");
}
const survey = await getSurvey(surveyId);
const [survey, team] = await Promise.all([getSurvey(surveyId), getTeamByEnvironmentId(environmentId)]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
const { environmentId } = survey;
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
return responses.notFoundResponse("TeamByEnvironmentId", environmentId);
}

View File

@@ -1,43 +0,0 @@
import { getSettings } from "@/app/lib/api/clientSettings";
import { responses } from "@/app/lib/api/response";
import { createPerson } from "@formbricks/lib/person/service";
import { createSession } from "@formbricks/lib/session/service";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(
_: Request,
{ params }: { params: { environmentId: string } }
): Promise<NextResponse> {
const { environmentId } = params;
if (!environmentId) {
return responses.badRequestResponse(
"Missing environmentId",
{
missing_field: "environmentId",
},
true
);
}
try {
const person = await createPerson(environmentId);
const session = await createSession(person.id);
const settings = await getSettings(environmentId, person.id);
return responses.successResponse(
{
person,
session,
settings,
},
true
);
} catch (error) {
return responses.internalServerErrorResponse(error.message, true);
}
}

View File

@@ -1,122 +0,0 @@
import { getUpdatedState } from "@/app/api/v1/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getDisplaysByPersonId, updateDisplay } from "@formbricks/lib/display/service";
import { personCache } from "@formbricks/lib/person/cache";
import { deletePerson, selectPerson, transformPrismaPerson } from "@formbricks/lib/person/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, { params }): Promise<NextResponse> {
try {
const { personId } = params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId, sessionId } = inputValidation.data;
let returnedPerson;
// check if person with this userId exists
const person = await prisma.person.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeClass: {
name: "userId",
},
value: userId,
},
},
},
select: selectPerson,
});
// if person exists, reconnect displays, session and delete old user
if (person) {
const displays = await getDisplaysByPersonId(personId);
await Promise.all(displays.map((display) => updateDisplay(display.id, { personId: person.id })));
// reconnect session to new person
await prisma.session.update({
where: {
id: sessionId,
},
data: {
person: {
connect: {
id: person.id,
},
},
},
});
// delete old person
await deletePerson(personId);
returnedPerson = person;
} else {
// update person with userId
returnedPerson = await prisma.person.update({
where: {
id: personId,
},
data: {
attributes: {
create: {
value: userId,
attributeClass: {
connect: {
name_environmentId: {
name: "userId",
environmentId,
},
},
},
},
},
},
select: selectPerson,
});
personCache.revalidate({
id: returnedPerson.id,
environmentId: returnedPerson.environmentId,
});
}
const transformedPerson = transformPrismaPerson(returnedPerson);
personCache.revalidate({
id: transformedPerson.id,
environmentId: environmentId,
});
surveyCache.revalidate({
environmentId,
});
const state = await getUpdatedState(environmentId, transformedPerson.id, sessionId);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
}

View File

@@ -1,44 +0,0 @@
import { prisma } from "@formbricks/database";
import { TPerson } from "@formbricks/types/people";
import { transformPrismaPerson } from "@formbricks/lib/person/service";
import { personCache } from "@formbricks/lib/person/cache";
const select = {
id: true,
environmentId: true,
createdAt: true,
updatedAt: true,
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
};
export const createPerson = async (environmentId: string): Promise<TPerson> => {
const prismaPerson = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
},
select,
});
const person = transformPrismaPerson(prismaPerson);
personCache.revalidate({
id: person.id,
environmentId: person.environmentId,
});
return person;
};

View File

@@ -1,17 +0,0 @@
import { prisma } from "@formbricks/database";
import { TSession } from "@formbricks/types/sessions";
export const createSession = async (personId: string): Promise<Partial<TSession>> => {
return prisma.session.create({
data: {
person: {
connect: {
id: personId,
},
},
},
select: {
id: true,
},
});
};

View File

@@ -72,7 +72,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
triggers: {
select: {
id: true,
eventClass: {
actionClass: {
select: {
id: true,
name: true,
@@ -181,7 +181,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
return {
id: survey.id,
questions: JSON.parse(JSON.stringify(survey.questions)),
triggers: survey.triggers.map((trigger) => trigger.eventClass.name),
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)),
welcomeCard: JSON.parse(JSON.stringify(survey.welcomeCard)),
autoClose: survey.autoClose,
@@ -189,7 +189,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
};
});
const noCodeEvents = await prisma.eventClass.findMany({
const noCodeEvents = await prisma.actionClass.findMany({
where: {
environmentId,
type: "noCode",

View File

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

View File

@@ -58,6 +58,7 @@ export default function LinkSurvey({
new ResponseQueue(
{
apiHost: webAppUrl,
environmentId: survey.environmentId,
retryAttempts: 2,
onResponseSendingFailed: (response) => {
alert(`Failed to send response: ${JSON.stringify(response, null, 2)}`);

View File

@@ -15,10 +15,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
}
// POST
else if (req.method === "POST") {
const { sessionId, eventName, properties } = req.body;
const { personId, eventName, properties } = req.body;
if (!sessionId) {
return res.status(400).json({ message: "Missing sessionId" });
if (!personId) {
return res.status(400).json({ message: "Missing personId" });
}
if (!eventName) {
return res.status(400).json({ message: "Missing eventName" });
@@ -29,15 +29,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
eventType = "automatic";
}
const eventData = await prisma.event.create({
const eventData = await prisma.action.create({
data: {
properties,
session: {
person: {
connect: {
id: sessionId,
id: personId,
},
},
eventClass: {
actionClass: {
connectOrCreate: {
where: {
name_environmentId: {

View File

@@ -1,143 +0,0 @@
import { getSettings } from "@/app/lib/api/clientSettings";
import { prisma } from "@formbricks/database";
import { personCache } from "@formbricks/lib/person/cache";
import { deletePerson } from "@formbricks/lib/person/service";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const personId = req.query.personId?.toString();
if (!personId) {
return res.status(400).json({ message: "Missing personId" });
}
// CORS
if (req.method === "OPTIONS") {
res.status(200).end();
}
// POST
else if (req.method === "POST") {
const { userId, sessionId } = req.body;
if (!userId) {
return res.status(400).json({ message: "Missing userId" });
}
if (!sessionId) {
return res.status(400).json({ message: "Missing sessionId" });
}
let person;
// check if person exists
const existingPerson = await prisma.person.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeClass: {
name: "userId",
},
value: userId,
},
},
},
select: {
id: true,
environmentId: true,
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// if person exists, reconnect session and delete old user
if (existingPerson) {
// reconnect session to new person
await prisma.session.update({
where: {
id: sessionId,
},
data: {
person: {
connect: {
id: existingPerson.id,
},
},
},
});
// delete old person
await deletePerson(personId);
person = existingPerson;
} else {
// update person
person = await prisma.person.update({
where: {
id: personId,
},
data: {
attributes: {
create: {
value: userId,
attributeClass: {
connect: {
name_environmentId: {
name: "userId",
environmentId,
},
},
},
},
},
},
select: {
id: true,
environmentId: true,
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
id: true,
name: true,
},
},
},
},
},
});
personCache.revalidate({
id: person.id,
environmentId: person.environmentId,
});
}
personCache.revalidate({
id: person.id,
environmentId: person.environmentId,
});
const settings = await getSettings(environmentId, person.id);
// return updated person and settings
return res.json({ person, settings });
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -1,8 +1,8 @@
import { sendToPipeline } from "@/app/lib/pipelines";
import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { transformPrismaPerson } from "@formbricks/lib/person/service";
import { responseCache } from "@formbricks/lib/response/cache";
import { TPerson } from "@formbricks/types/people";
import { TPipelineInput } from "@formbricks/types/pipelines";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -69,6 +69,8 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
person: {
select: {
id: true,
userId: true,
environmentId: true,
createdAt: true,
updatedAt: true,
attributes: {
@@ -122,21 +124,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
environmentId,
});
const transformPrismaPerson = (person): TPerson => {
const attributes = person.attributes.reduce((acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
}, {} as Record<string, string | number>);
return {
id: person.id,
attributes: attributes,
createdAt: person.createdAt,
updatedAt: person.updatedAt,
environmentId: environmentId,
};
};
const responseData: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,

View File

@@ -1,8 +1,8 @@
import { sendToPipeline } from "@/app/lib/pipelines";
import { prisma } from "@formbricks/database";
import { transformPrismaPerson } from "@formbricks/lib/person/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TPerson } from "@formbricks/types/people";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -113,6 +113,8 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
person: {
select: {
id: true,
userId: true,
environmentId: true,
createdAt: true,
updatedAt: true,
attributes: {
@@ -159,21 +161,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
});
const transformPrismaPerson = (person): TPerson => {
const attributes = person.attributes.reduce((acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
}, {} as Record<string, string | number>);
return {
id: person.id,
attributes: attributes,
createdAt: person.createdAt,
updatedAt: person.updatedAt,
environmentId: environmentId,
};
};
const responseData: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,

View File

@@ -1,38 +0,0 @@
import { createSession } from "@/app/lib/api/clientSession";
import { getSettings } from "@/app/lib/api/clientSettings";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
// CORS
if (req.method === "OPTIONS") {
res.status(200).end();
}
// GET
else if (req.method === "POST") {
const { personId } = req.body;
if (!personId) {
return res.status(400).json({ message: "Missing personId" });
}
try {
const session = await createSession(personId);
const settings = await getSettings(environmentId, personId);
return res.json({ session, settings });
} catch (error) {
res.status(500).json({ message: error.message });
}
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}

View File

@@ -5,19 +5,28 @@ import { TDisplay, TDisplayCreateInput, TDisplayUpdateInput } from "@formbricks/
export class DisplayAPI {
private apiHost: string;
private environmentId: string;
constructor(baseUrl: string) {
constructor(baseUrl: string, environmentId: string) {
this.apiHost = baseUrl;
this.environmentId = environmentId;
}
async create(displayInput: TDisplayCreateInput): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(this.apiHost, "/api/v1/client/displays", "POST", displayInput);
async create(
displayInput: Omit<TDisplayCreateInput, "environmentId">
): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
}
async update(
displayId: string,
displayInput: TDisplayUpdateInput
): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/displays/${displayId}`, "PUT", displayInput);
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/displays/${displayId}`,
"PUT",
displayInput
);
}
}

View File

@@ -7,9 +7,9 @@ export class Client {
display: DisplayAPI;
constructor(options: ApiConfig) {
const { apiHost } = options;
const { apiHost, environmentId } = options;
this.response = new ResponseAPI(apiHost);
this.display = new DisplayAPI(apiHost);
this.response = new ResponseAPI(apiHost, environmentId);
this.display = new DisplayAPI(apiHost, environmentId);
}
}

View File

@@ -7,13 +7,15 @@ type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: s
export class ResponseAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string) {
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async create(responseInput: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, "/api/v1/client/responses", "POST", responseInput);
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}
async update({
@@ -21,7 +23,7 @@ export class ResponseAPI {
finished,
data,
}: TResponseUpdateInputWithResponseId): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/responses/${responseId}`, "PUT", {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
data,
});

View File

@@ -16,8 +16,8 @@ import { TUserNotificationSettings } from "@formbricks/types/users";
declare global {
namespace PrismaJson {
export type EventProperties = { [key: string]: string };
export type EventClassNoCodeConfig = TActionClassNoCodeConfig;
export type ActionProperties = { [key: string]: string };
export type ActionClassNoCodeConfig = TActionClassNoCodeConfig;
export type IntegrationConfig = TIntegrationConfig;
export type ResponseData = TResponseData;
export type ResponseMeta = TResponseMeta;

View File

@@ -0,0 +1,82 @@
/*
Warnings:
- You are about to drop the `Event` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Session` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Event" DROP CONSTRAINT "Event_eventClassId_fkey";
-- DropForeignKey
ALTER TABLE "Event" DROP CONSTRAINT "Event_sessionId_fkey";
-- DropForeignKey
ALTER TABLE "Session" DROP CONSTRAINT "Session_personId_fkey";
-- DropTable
DROP TABLE "Event";
-- DropTable
DROP TABLE "Session";
ALTER TABLE "EventClass" RENAME TO "ActionClass";
-- AlterTable
ALTER TABLE "ActionClass" RENAME CONSTRAINT "EventClass_pkey" TO "ActionClass_pkey";
-- RenameForeignKey
ALTER TABLE "ActionClass" RENAME CONSTRAINT "EventClass_environmentId_fkey" TO "ActionClass_environmentId_fkey";
-- RenameIndex
ALTER INDEX "EventClass_name_environmentId_key" RENAME TO "ActionClass_name_environmentId_key";
-- CreateTable
CREATE TABLE "Action" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actionClassId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"properties" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "Action_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Action" ADD CONSTRAINT "Action_actionClassId_fkey" FOREIGN KEY ("actionClassId") REFERENCES "ActionClass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Action" ADD CONSTRAINT "Action_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SurveyTrigger" RENAME COLUMN "eventClassId" TO "actionClassId";
-- RenameForeignKey
ALTER TABLE "SurveyTrigger" RENAME CONSTRAINT "SurveyTrigger_eventClassId_fkey" TO "SurveyTrigger_actionClassId_fkey";
-- RenameIndex
ALTER INDEX "SurveyTrigger_surveyId_eventClassId_key" RENAME TO "SurveyTrigger_surveyId_actionClassId_key";
ALTER TYPE "EventType" RENAME TO "ActionType";
-- CreateIndex
CREATE INDEX "Action_personId_idx" ON "Action"("personId");
-- CreateIndex
CREATE INDEX "Action_actionClassId_idx" ON "Action"("actionClassId");
/*
Warnings:
- A unique constraint covering the columns `[environmentId,userId]` on the table `Person` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "userId" SERIAL NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Person_environmentId_userId_key" ON "Person"("environmentId", "userId");
-- AlterTable
ALTER TABLE "Person" ALTER COLUMN "userId" DROP DEFAULT,
ALTER COLUMN "userId" SET DATA TYPE TEXT;
DROP SEQUENCE "Person_userId_seq";

View File

@@ -89,15 +89,17 @@ model AttributeClass {
model Person {
id String @id @default(cuid())
userId String
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
responses Response[]
sessions Session[]
attributes Attribute[]
displays Display[]
actions Action[]
@@unique([environmentId, userId])
@@index([environmentId])
}
@@ -195,15 +197,15 @@ model Display {
}
model SurveyTrigger {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
eventClass EventClass @relation(fields: [eventClassId], references: [id], onDelete: Cascade)
eventClassId String
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade)
actionClassId String
@@unique([surveyId, eventClassId])
@@unique([surveyId, actionClassId])
@@index([surveyId])
}
@@ -293,51 +295,44 @@ model Survey {
@@index([environmentId])
}
model Event {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
eventClass EventClass? @relation(fields: [eventClassId], references: [id])
eventClassId String?
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sessionId String
/// @zod.custom(imports.ZEventProperties)
/// @zod.custom(imports.ZEventProperties)
/// [EventProperties]
properties Json @default("{}")
}
enum EventType {
enum ActionType {
code
noCode
automatic
}
model EventClass {
model ActionClass {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
description String?
type EventType
events Event[]
type ActionType
/// @zod.custom(imports.ZActionClassNoCodeConfig)
/// [EventClassNoCodeConfig]
/// [ActionClassNoCodeConfig]
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
surveys SurveyTrigger[]
actions Action[]
@@unique([name, environmentId])
}
model Session {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
expiresAt DateTime @default(now())
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String
events Event[]
model Action {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade)
actionClassId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
personId String
/// @zod.custom(imports.ZActionProperties)
/// @zod.custom(imports.ZActionProperties)
/// [ActionProperties]
properties Json @default("{}")
@@index([personId])
@@index([actionClassId])
}
enum EnvironmentType {
@@ -373,7 +368,7 @@ model Environment {
widgetSetupCompleted Boolean @default(false)
surveys Survey[]
people Person[]
eventClasses EventClass[]
actionClasses ActionClass[]
attributeClasses AttributeClass[]
apiKeys ApiKey[]
webhooks Webhook[]

View File

@@ -1,6 +1,6 @@
import z from "zod";
export const ZEventProperties = z.record(z.string());
export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
export { ZIntegrationConfig } from "@formbricks/types/integration";

View File

@@ -4,7 +4,7 @@
var t = document.createElement("script");
(t.type = "text/javascript"),
(t.async = !0),
(t.src = "https://unpkg.com/@formbricks/js@^1.1.4/dist/index.umd.js");
(t.src = "https://unpkg.com/@formbricks/js@^1.2.0/dist/index.umd.js");
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.1.5",
"version": "1.2.0",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",

View File

@@ -19,8 +19,8 @@ const init = async (initConfig: TJsConfigInput) => {
await queue.wait();
};
const setUserId = async (userId: string | number): Promise<void> => {
queue.add(true, setPersonUserId, userId);
const setUserId = async (): Promise<void> => {
queue.add(true, setPersonUserId);
await queue.wait();
};

View File

@@ -14,13 +14,15 @@ export const trackAction = async (
): Promise<Result<void, NetworkError>> => {
const input: TJsActionInput = {
environmentId: config.get().environmentId,
sessionId: config.get().state?.session?.id ?? "",
userId: config.get().state?.person?.userId,
name,
properties: properties || {},
};
if (!intentsToNotCreateOnApp.includes(name)) {
const res = await fetch(`${config.get().apiHost}/api/v1/js/actions`, {
// 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",
@@ -34,7 +36,7 @@ export const trackAction = async (
return err({
code: "network_error",
message: `Error tracking event: ${JSON.stringify(error)}`,
message: `Error tracking action: ${JSON.stringify(error)}`,
status: res.status,
url: res.url,
responseMessage: error.message,
@@ -42,7 +44,7 @@ export const trackAction = async (
}
}
logger.debug(`Formbricks: Event "${name}" tracked`);
logger.debug(`Formbricks: Action "${name}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = config.get().state?.surveys;

View File

@@ -1,4 +1,4 @@
import { TJsConfig } from "@formbricks/types/js";
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { Result, err, ok, wrapThrows } from "./errors";
export const LOCAL_STORAGE_KEY = "formbricks-js";
@@ -14,11 +14,14 @@ export class Config {
return Config.instance;
}
public update(newConfig: TJsConfig): void {
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
const expiresAt = new Date(new Date().getTime() + 15 * 60000); // 15 minutes in the future
this.config = {
...this.config,
...newConfig,
expiresAt,
};
this.saveToLocalStorage();
@@ -39,6 +42,13 @@ export class Config {
// TODO: validate config
// This is a hack to get around the fact that we don't have a proper
// way to validate the config yet.
const parsedConfig = JSON.parse(savedConfig) as TJsConfig;
// check if the config has expired
if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) {
return err(new Error("Config in local storage has expired"));
}
return ok(JSON.parse(savedConfig) as TJsConfig);
}
}

View File

@@ -10,12 +10,12 @@ import {
removeClickEventListener,
removePageUrlEventListeners,
} from "./noCodeActions";
import { addSyncEventListener, removeSyncEventListener } from "./sync";
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (debug: boolean = false): void => {
addSyncEventListener(debug);
export const addEventListeners = (): void => {
addExpiryCheckListener();
addPageUrlEventListeners();
addClickEventListener();
addExitIntentListener();
@@ -25,7 +25,7 @@ export const addEventListeners = (debug: boolean = false): void => {
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
window.addEventListener("beforeunload", () => {
removeSyncEventListener();
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
@@ -37,7 +37,7 @@ export const addCleanupEventListeners = (): void => {
export const removeCleanupEventListeners = (): void => {
if (!areRemoveEventListenersAdded) return;
window.removeEventListener("beforeunload", () => {
removeSyncEventListener();
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
@@ -47,7 +47,7 @@ export const removeCleanupEventListeners = (): void => {
};
export const removeAllEventListeners = (): void => {
removeSyncEventListener();
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();

View File

@@ -13,10 +13,9 @@ import {
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
import { checkPageUrl } from "./noCodeActions";
import { resetPerson } from "./person";
import { isExpired } from "./session";
import { sync } from "./sync";
import { addWidgetContainer } from "./widget";
import { trackAction } from "./actions";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -70,46 +69,40 @@ export const initialize = async (
localConfigResult.ok &&
localConfigResult.value.state &&
localConfigResult.value.environmentId === c.environmentId &&
localConfigResult.value.apiHost === c.apiHost
localConfigResult.value.apiHost === c.apiHost &&
localConfigResult.value.state?.person?.userId === c.userId &&
localConfigResult.value.expiresAt // only accept config when they follow new config version with expiresAt
) {
const { state, apiHost, environmentId } = localConfigResult.value;
logger.debug("Found existing configuration. Checking session.");
const existingSession = state.session;
config.update(localConfigResult.value);
if (isExpired(existingSession)) {
logger.debug("Session expired. Resyncing.");
try {
await sync({
apiHost,
environmentId,
personId: state.person.id,
sessionId: existingSession.id,
});
} catch (e) {
logger.debug("Sync failed. Clearing config and starting from scratch.");
await resetPerson();
return await initialize(c);
}
logger.debug("Found existing configuration.");
if (localConfigResult.value.expiresAt < new Date()) {
logger.debug("Configuration expired.");
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} else {
logger.debug("Session valid. Continuing.");
// continue for now - next sync will check complete state
logger.debug("Configuration not expired. Extending expiration.");
config.update(localConfigResult.value);
}
} else {
logger.debug("No valid configuration found. Creating new config.");
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug("Syncing.");
// when the local storage is expired / empty, we sync to get the latest config
await sync({
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
// and track the new session event
trackAction("New Session");
}
logger.debug("Adding event listeners");
addEventListeners(c.debug);
addEventListeners();
addCleanupEventListeners();
isInitialized = true;

View File

@@ -1,4 +1,4 @@
import { TJsPeopleAttributeInput, TJsPeopleUserIdInput, TJsState } from "@formbricks/types/js";
import { TJsPeopleAttributeInput, TJsState } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { Config } from "./config";
import {
@@ -16,51 +16,11 @@ import { Logger } from "./logger";
const config = Config.getInstance();
const logger = Logger.getInstance();
export const updatePersonUserId = async (
userId: string
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person.id)
return err({
code: "missing_person",
message: "Unable to update userId. No person set.",
});
const url = `${config.get().apiHost}/api/v1/js/people/${config.get().state.person.id}/set-user-id`;
const input: TJsPeopleUserIdInput = {
environmentId: config.get().environmentId,
userId,
sessionId: config.get().state.session.id,
};
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
const jsonRes = await res.json();
if (!res.ok) {
return err({
code: "network_error",
message: "Error updating person",
status: res.status,
url,
responseMessage: jsonRes.message,
});
}
return ok(jsonRes.data as TJsState);
};
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person.id) {
if (!config.get().state.person || !config.get().state.person?.id) {
return err({
code: "missing_person",
message: "Unable to update attribute. No person set.",
@@ -68,14 +28,14 @@ export const updatePersonAttribute = async (
}
const input: TJsPeopleAttributeInput = {
environmentId: config.get().environmentId,
sessionId: config.get().state.session.id,
key,
value,
};
const res = await fetch(
`${config.get().apiHost}/api/v1/js/people/${config.get().state.person.id}/set-attribute`,
`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
config.get().state.person?.id
}/set-attribute`,
{
method: "POST",
headers: {
@@ -114,33 +74,10 @@ export const hasAttributeKey = (key: string): boolean => {
return false;
};
export const setPersonUserId = async (
userId: string | number
): Promise<Result<void, NetworkError | MissingPersonError | AttributeAlreadyExistsError>> => {
logger.debug("setting userId: " + userId);
// check if attribute already exists with this value
if (hasAttributeValue("userId", userId.toString())) {
logger.debug("userId already set to this value. Skipping update.");
return okVoid();
}
if (hasAttributeKey("userId")) {
return err({
code: "attribute_already_exists",
message: "userId cannot be changed after it has been set. You need to reset first",
});
}
const result = await updatePersonUserId(userId.toString());
if (result.ok !== true) return err(result.error);
const state = result.value;
config.update({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
state,
});
export const setPersonUserId = async (): Promise<
Result<void, NetworkError | MissingPersonError | AttributeAlreadyExistsError>
> => {
logger.error("'setUserId' is no longer supported. Please set the userId in the init call instead.");
return okVoid();
};
@@ -181,6 +118,7 @@ export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
const syncParams = {
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().state?.person?.userId,
};
await logoutPerson();
try {
@@ -191,6 +129,6 @@ export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
}
};
export const getPerson = (): TPerson => {
export const getPerson = (): TPerson | null => {
return config.get().state.person;
};

View File

@@ -1,6 +0,0 @@
import { TSession } from "@formbricks/types/sessions";
export const isExpired = (session: TSession): boolean => {
if (!session) return true;
return session.expiresAt < new Date();
};

View File

@@ -1,35 +1,51 @@
import { TJsState, TJsSyncParams } from "@formbricks/types/js";
import { trackAction } from "./actions";
import { Config } from "./config";
import { NetworkError, Result, err, ok } from "./errors";
import { Logger } from "./logger";
import packageJson from "../../package.json";
const config = Config.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
const syncWithBackend = async ({
apiHost,
environmentId,
personId,
sessionId,
userId,
}: TJsSyncParams): Promise<Result<TJsState, NetworkError>> => {
const url = `${apiHost}/api/v1/js/sync`;
const url = `${apiHost}/api/v1/client/${environmentId}/in-app/sync/${userId}`;
const publicUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
// if user id is available
if (!userId) {
// public survey
const response = await fetch(publicUrl);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
}
// userId is available, call the api with the `userId` param
const response = await fetch(url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
environmentId,
personId,
sessionId,
jsVersion: packageJson.version,
}),
});
if (!response.ok) {
const jsonRes = await response.json();
@@ -42,7 +58,10 @@ const syncWithBackend = async ({
});
}
return ok((await response.json()).data as TJsState);
const data = await response.json();
const { data: state } = data;
return ok(state as TJsState);
};
export const sync = async (params: TJsSyncParams): Promise<void> => {
@@ -50,7 +69,7 @@ export const sync = async (params: TJsSyncParams): Promise<void> => {
const syncResult = await syncWithBackend(params);
if (syncResult?.ok !== true) {
logger.error(`Sync failed: ${JSON.stringify(syncResult.error)}`);
return;
throw syncResult.error;
}
const state = syncResult.value;
@@ -60,42 +79,112 @@ export const sync = async (params: TJsSyncParams): Promise<void> => {
} catch (e) {
// ignore error
}
config.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
state,
});
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
// if session is new, track action
if (!oldState?.session || oldState.session.id !== state.session.id) {
const trackActionResult = await trackAction("New Session");
if (trackActionResult.ok !== true) {
logger.error(`Action tracking failed: ${trackActionResult.error}`);
}
// before finding the surveys, check for public use
if (!state.person?.id) {
// unidentified user
// set the displays and filter out surveys
const publicState = {
...state,
displays: oldState?.displays || [],
};
const filteredState = filterPublicSurveys(publicState);
// update config
config.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
state: filteredState,
});
const surveyNames = filteredState.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
} else {
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
}
} catch (error) {
logger.error(`Error during sync: ${error}`);
throw error;
}
};
export const addSyncEventListener = (debug: boolean = false): void => {
const updateInterval = debug ? 1000 * 60 : 1000 * 60 * 5; // 5 minutes in production, 1 minute in debug mode
export const filterPublicSurveys = (state: TJsState): TJsState => {
const { displays, product } = state;
let { surveys } = state;
if (!displays) {
return state;
}
// filter surveys that meet the displayOption criteria
let filteredSurveys = 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.responded).length === 0;
} else {
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays.length > 0 ? displays[displays.length - 1] : undefined;
// filter surveys that meet the recontactDays criteria
filteredSurveys = filteredSurveys.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 {
...state,
surveys: filteredSurveys,
};
};
export const addExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every minute
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
personId: config.get().state?.person?.id,
sessionId: config.get().state?.session?.id,
userId: config.get().state?.person?.userId,
// personId: config.get().state?.person?.id,
});
}, updateInterval);
}
};
export const removeSyncEventListener = (): void => {
export const removeExpiryCheckListener = (): void => {
if (typeof window !== "undefined" && syncIntervalId !== null) {
window.clearInterval(syncIntervalId);

View File

@@ -1,12 +1,12 @@
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import SurveyState from "@formbricks/lib/surveyState";
import { renderSurveyModal } from "@formbricks/surveys";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TJSStateDisplay, TSurveyWithTriggers } from "@formbricks/types/js";
import { TResponseUpdate } from "@formbricks/types/responses";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { Logger } from "./logger";
import { sync } from "./sync";
import { filterPublicSurveys, sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
const containerId = "formbricks-web-container";
@@ -33,6 +33,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
const responseQueue = new ResponseQueue(
{
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
retryAttempts: 2,
onResponseSendingFailed: (response) => {
alert(`Failed to send response: ${JSON.stringify(response, null, 2)}`);
@@ -58,13 +59,33 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
highlightBorderColor,
placement,
onDisplay: async () => {
// if config does not have a person, we store the displays in local storage
if (!config.get().state.person || !config.get().state.person?.userId) {
const localDisplay: TJSStateDisplay = {
createdAt: new Date(),
surveyId: survey.id,
responded: false,
};
const existingDisplays = config.get().state.displays;
const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay];
const previousConfig = config.get();
config.update({
...previousConfig,
state: {
...previousConfig.state,
displays,
},
});
}
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.display.create({
surveyId: survey.id,
personId: config.get().state.person.id,
userId: config.get().state.person?.userId,
});
if (!res.ok) {
throw new Error("Could not create display");
@@ -75,7 +96,29 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
responseQueue.updateSurveyState(surveyState);
},
onResponse: (responseUpdate: TResponseUpdate) => {
surveyState.updatePersonId(config.get().state.person.id);
// if user is unidentified, update the display in local storage if not already updated
if (!config.get().state.person || !config.get().state.person?.userId) {
const displays = config.get().state.displays;
const lastDisplay = displays && displays[displays.length - 1];
if (!lastDisplay) {
throw new Error("No lastDisplay found");
}
if (!lastDisplay.responded) {
lastDisplay.responded = true;
const previousConfig = config.get();
config.update({
...previousConfig,
state: {
...previousConfig.state,
displays,
},
});
}
}
if (config.get().state.person && config.get().state.person?.id) {
surveyState.updatePersonId(config.get().state.person?.id!);
}
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
@@ -92,12 +135,24 @@ export const closeSurvey = async (): Promise<void> => {
document.getElementById(containerId)?.remove();
addWidgetContainer();
// if unidentified user, refilter the surveys
if (!config.get().state.person || !config.get().state.person?.userId) {
const state = config.get().state;
const updatedState = filterPublicSurveys(state);
config.update({
...config.get(),
state: updatedState,
});
surveyRunning = false;
return;
}
// for identified users we sync to get the latest surveys
try {
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
personId: config.get().state.person?.id,
sessionId: config.get().state.session?.id,
userId: config.get().state?.person?.userId,
});
surveyRunning = false;
} catch (e) {

View File

@@ -32,12 +32,6 @@ export const mockInitResponse = () => {
updatedAt: "2021-03-09T15:00:00.000Z",
attributes: {},
},
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
surveys: [
{
id: surveyId,
@@ -171,12 +165,6 @@ export const mockUpdateEmailResponse = () => {
data: {
surveys: [],
noCodeActionClasses: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
person: {
id: initialPersonUid,
environmentId,
@@ -218,10 +206,7 @@ export const mockResetResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
settings: {
surveys: [],
noCodeEvents: [],
},
surveys: [],
person: {
id: newPersonUid,
environmentId,

View File

@@ -41,6 +41,7 @@ test("Formbricks should Initialise", async () => {
await formbricks.init({
environmentId,
apiHost,
userId: initialUserId,
});
const configFromBrowser = localStorage.getItem("formbricks-js");
@@ -60,20 +61,6 @@ test("Formbricks should get the current person with no attributes", () => {
expect(Object.keys(currentStatePersonAttributes)).toHaveLength(0);
});
test("Formbricks should set userId", async () => {
mockSetUserIdResponse();
await formbricks.setUserId(initialUserId);
const currentStatePerson = formbricks.getPerson();
const currentStatePersonAttributes = currentStatePerson.attributes;
const numberOfUserAttributes = Object.keys(currentStatePersonAttributes).length;
expect(numberOfUserAttributes).toStrictEqual(1);
const userId = currentStatePersonAttributes.userId;
expect(userId).toStrictEqual(initialUserId);
});
test("Formbricks should set email", async () => {
mockSetEmailIdResponse();
await formbricks.setEmail(initialUserEmail);

View File

@@ -14,9 +14,12 @@ export const actionCache = {
return `environments-${personId}-actions`;
},
},
revalidate({ environmentId }: RevalidateProps): void {
revalidate({ environmentId, personId }: RevalidateProps): void {
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (personId) {
revalidateTag(this.tag.byPersonId(personId));
}
},
};

View File

@@ -5,15 +5,15 @@ import { TActionClassType } from "@formbricks/types/actionClasses";
import { TAction, TActionInput, ZActionInput } from "@formbricks/types/actions";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { Prisma } from "@prisma/client";
import { revalidateTag, unstable_cache } from "next/cache";
import { unstable_cache } from "next/cache";
import { actionClassCache } from "../actionClass/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getSession } from "../session/service";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
import { getPersonByUserId } from "../person/service";
export const getLatestActionByEnvironmentId = async (environmentId: string): Promise<TAction | null> => {
const action = await unstable_cache(
@@ -21,9 +21,9 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
validateInputs([environmentId, ZId]);
try {
const actionPrisma = await prisma.event.findFirst({
const actionPrisma = await prisma.action.findFirst({
where: {
eventClass: {
actionClass: {
environmentId: environmentId,
},
},
@@ -31,7 +31,7 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
createdAt: "desc",
},
include: {
eventClass: true,
actionClass: true,
},
});
if (!actionPrisma) {
@@ -40,9 +40,10 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
sessionId: actionPrisma.sessionId,
// sessionId: actionPrisma.sessionId,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.eventClass,
actionClass: actionPrisma.actionClass,
};
return action;
} catch (error) {
@@ -75,10 +76,10 @@ export const getActionsByPersonId = async (personId: string, page?: number): Pro
async () => {
validateInputs([personId, ZId], [page, ZOptionalNumber]);
const actionsPrisma = await prisma.event.findMany({
const actionsPrisma = await prisma.action.findMany({
where: {
session: {
personId: personId,
person: {
id: personId,
},
},
orderBy: {
@@ -87,7 +88,7 @@ export const getActionsByPersonId = async (personId: string, page?: number): Pro
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
include: {
eventClass: true,
actionClass: true,
},
});
@@ -97,9 +98,10 @@ export const getActionsByPersonId = async (personId: string, page?: number): Pro
actions.push({
id: action.id,
createdAt: action.createdAt,
sessionId: action.sessionId,
personId: action.personId,
// sessionId: action.sessionId,
properties: action.properties,
actionClass: action.eventClass,
actionClass: action.actionClass,
});
});
return actions;
@@ -124,9 +126,9 @@ export const getActionsByEnvironmentId = async (environmentId: string, page?: nu
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const actionsPrisma = await prisma.event.findMany({
const actionsPrisma = await prisma.action.findMany({
where: {
eventClass: {
actionClass: {
environmentId: environmentId,
},
},
@@ -136,7 +138,7 @@ export const getActionsByEnvironmentId = async (environmentId: string, page?: nu
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
include: {
eventClass: true,
actionClass: true,
},
});
const actions: TAction[] = [];
@@ -145,9 +147,10 @@ export const getActionsByEnvironmentId = async (environmentId: string, page?: nu
actions.push({
id: action.id,
createdAt: action.createdAt,
sessionId: action.sessionId,
// sessionId: action.sessionId,
personId: action.personId,
properties: action.properties,
actionClass: action.eventClass,
actionClass: action.actionClass,
});
});
return actions;
@@ -177,17 +180,17 @@ export const getActionsByEnvironmentId = async (environmentId: string, page?: nu
export const createAction = async (data: TActionInput): Promise<TAction> => {
validateInputs([data, ZActionInput]);
const { environmentId, name, properties, sessionId } = data;
const { environmentId, name, properties, userId } = data;
let eventType: TActionClassType = "code";
let actionType: TActionClassType = "code";
if (name === "Exit Intent (Desktop)" || name === "50% Scroll") {
eventType = "automatic";
actionType = "automatic";
}
const session = await getSession(sessionId);
const person = await getPersonByUserId(userId, environmentId);
if (!session) {
throw new ResourceNotFoundError("Session", sessionId);
if (!person) {
throw new Error("Person not found");
}
let actionClass = await getActionClassByEnvironmentIdAndName(environmentId, name);
@@ -195,20 +198,20 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
if (!actionClass) {
actionClass = await createActionClass(environmentId, {
name,
type: eventType,
type: actionType,
environmentId,
});
}
const action = await prisma.event.create({
const action = await prisma.action.create({
data: {
properties,
session: {
person: {
connect: {
id: sessionId,
id: person.id,
},
},
eventClass: {
actionClass: {
connect: {
id: actionClass.id,
},
@@ -216,15 +219,15 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
},
});
revalidateTag(sessionId);
actionCache.revalidate({
environmentId,
personId: person.id,
});
return {
id: action.id,
createdAt: action.createdAt,
sessionId: action.sessionId,
personId: action.personId,
properties: action.properties,
actionClass,
};
@@ -236,9 +239,9 @@ export const getActionCountInLastHour = async (actionClassId: string): Promise<n
validateInputs([actionClassId, ZId]);
try {
const numEventsLastHour = await prisma.event.count({
const numEventsLastHour = await prisma.action.count({
where: {
eventClassId: actionClassId,
actionClassId: actionClassId,
createdAt: {
gte: new Date(Date.now() - 60 * 60 * 1000),
},
@@ -262,9 +265,9 @@ export const getActionCountInLast24Hours = async (actionClassId: string): Promis
validateInputs([actionClassId, ZId]);
try {
const numEventsLast24Hours = await prisma.event.count({
const numEventsLast24Hours = await prisma.action.count({
where: {
eventClassId: actionClassId,
actionClassId: actionClassId,
createdAt: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000),
},
@@ -288,9 +291,9 @@ export const getActionCountInLast7Days = async (actionClassId: string): Promise<
validateInputs([actionClassId, ZId]);
try {
const numEventsLast7Days = await prisma.event.count({
const numEventsLast7Days = await prisma.action.count({
where: {
eventClassId: actionClassId,
actionClassId: actionClassId,
createdAt: {
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},

View File

@@ -21,6 +21,7 @@ export const canUserUpdateActionClass = async (userId: string, actionClassId: st
if (!actionClass) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, actionClass.environmentId);
if (!hasAccessToEnvironment) return false;
return true;

View File

@@ -28,7 +28,7 @@ export const getActionClasses = (environmentId: string, page?: number): Promise<
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const actionClasses = await prisma.eventClass.findMany({
const actionClasses = await prisma.actionClass.findMany({
where: {
environmentId: environmentId,
},
@@ -61,7 +61,7 @@ export const getActionClassByEnvironmentIdAndName = async (
validateInputs([environmentId, ZId], [name, ZString]);
try {
const actionClass = await prisma.eventClass.findFirst({
const actionClass = await prisma.actionClass.findFirst({
where: {
name,
environmentId,
@@ -87,7 +87,7 @@ export const getActionClass = async (actionClassId: string): Promise<TActionClas
validateInputs([actionClassId, ZId]);
try {
const actionClass = await prisma.eventClass.findUnique({
const actionClass = await prisma.actionClass.findUnique({
where: {
id: actionClassId,
},
@@ -113,7 +113,7 @@ export const deleteActionClass = async (
validateInputs([environmentId, ZId], [actionClassId, ZId]);
try {
const result = await prisma.eventClass.delete({
const result = await prisma.actionClass.delete({
where: {
id: actionClassId,
},
@@ -141,7 +141,7 @@ export const createActionClass = async (
validateInputs([environmentId, ZId], [actionClass, ZActionClassInput]);
try {
const actionClassPrisma = await prisma.eventClass.create({
const actionClassPrisma = await prisma.actionClass.create({
data: {
name: actionClass.name,
description: actionClass.description,
@@ -174,7 +174,7 @@ export const updateActionClass = async (
validateInputs([environmentId, ZId], [actionClassId, ZId], [inputActionClass, ZActionClassInput.partial()]);
try {
const result = await prisma.eventClass.update({
const result = await prisma.actionClass.update({
where: {
id: actionClassId,
},

View File

@@ -221,6 +221,7 @@ export const authOptions: NextAuthOptions = {
{
accepted: true,
role: "owner",
// @ts-ignore
team: {
create: {
name: `${user.name}'s Team`,

View File

@@ -5,8 +5,10 @@ import { ZOptionalNumber } from "@formbricks/types/common";
import {
TDisplay,
TDisplayCreateInput,
TDisplayLegacyCreateInput,
TDisplayUpdateInput,
ZDisplayCreateInput,
ZDisplayLegacyCreateInput,
ZDisplayUpdateInput,
} from "@formbricks/types/displays";
import { ZId } from "@formbricks/types/environment";
@@ -14,6 +16,7 @@ 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 { validateInputs } from "../utils/validate";
import { displayCache } from "./cache";
import { formatDisplaysDateFields } from "./util";
@@ -71,6 +74,48 @@ export const updateDisplay = async (
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayCreateInput]);
try {
let person;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
}
const display = await prisma.display.create({
data: {
survey: {
connect: {
id: displayInput.surveyId,
},
},
...(person && {
person: {
connect: {
id: person.id,
},
},
}),
},
select: selectDisplay,
});
displayCache.revalidate({
id: display.id,
personId: display.personId,
surveyId: display.surveyId,
});
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const createDisplayLegacy = async (displayInput: TDisplayLegacyCreateInput): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayLegacyCreateInput]);
try {
const display = await prisma.display.create({
data: {

View File

@@ -184,7 +184,7 @@ export const createEnvironment = async (
type: environmentInput.type || "development",
product: { connect: { id: productId } },
widgetSetupCompleted: environmentInput.widgetSetupCompleted || false,
eventClasses: {
actionClasses: {
create: [
{
name: "New Session",

View File

@@ -1,19 +1,19 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError } from "@formbricks/types/errors";
import { TPerson, TPersonUpdateInput, ZPersonUpdateInput } from "@formbricks/types/people";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
import { getAttributeClassByName } from "../attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants";
import { ZString, ZOptionalNumber } from "@formbricks/types/common";
import { personCache } from "./cache";
export const selectPerson = {
id: true,
userId: true,
createdAt: true,
updatedAt: true,
environmentId: true,
@@ -28,6 +28,7 @@ export const selectPerson = {
attributeClass: {
select: {
name: true,
id: true,
},
},
},
@@ -36,6 +37,7 @@ export const selectPerson = {
type TransformPersonInput = {
id: string;
userId: string;
environmentId: string;
attributes: {
value: string;
@@ -55,6 +57,7 @@ export const transformPrismaPerson = (person: TransformPersonInput): TPerson =>
return {
id: person.id,
userId: person.userId,
attributes: attributes,
environmentId: person.environmentId,
createdAt: new Date(person.createdAt),
@@ -157,7 +160,7 @@ export const getPeopleCount = async (environmentId: string): Promise<number> =>
}
)();
export const createPerson = async (environmentId: string): Promise<TPerson> => {
export const createPerson = async (environmentId: string, userId: string): Promise<TPerson> => {
validateInputs([environmentId, ZId]);
try {
@@ -168,6 +171,7 @@ export const createPerson = async (environmentId: string): Promise<TPerson> => {
id: environmentId,
},
},
userId,
},
select: selectPerson,
});
@@ -243,13 +247,26 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
}
};
export const getOrCreatePersonByUserId = async (userId: string, environmentId: string): Promise<TPerson> => {
export const getPersonByUserId = async (userId: string, environmentId: string): Promise<TPerson | null> => {
const personPrisma = await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
where: {
environmentId,
userId,
},
select: selectPerson,
});
if (personWithUserId) {
return personWithUserId;
}
// Check if a person with the userId attribute exists
const personPrisma = await prisma.person.findFirst({
let personWithUserIdAttribute = await prisma.person.findFirst({
where: {
environmentId,
attributes: {
@@ -264,50 +281,78 @@ export const getOrCreatePersonByUserId = async (userId: string, environmentId: s
select: selectPerson,
});
if (personPrisma) {
return personPrisma;
} else {
// Create a new person with the userId attribute
const userIdAttributeClass = await getAttributeClassByName(environmentId, "userId");
const userIdAttributeClassId = personWithUserIdAttribute?.attributes.find(
(attr) => attr.attributeClass.name === "userId" && attr.value === userId
)?.attributeClass.id;
if (!userIdAttributeClass) {
throw new ResourceNotFoundError(
"Attribute class not found for the given environment",
environmentId
);
}
if (!personWithUserIdAttribute) {
return null;
}
const person = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
attributes: {
create: [
{
attributeClass: {
connect: {
id: userIdAttributeClass.id,
},
},
value: userId,
},
],
},
},
select: selectPerson,
});
personCache.revalidate({
id: person.id,
environmentId: person.environmentId,
personWithUserIdAttribute = await prisma.person.update({
where: {
id: personWithUserIdAttribute.id,
},
data: {
userId,
});
attributes: {
deleteMany: { attributeClassId: userIdAttributeClassId },
},
},
select: selectPerson,
});
personCache.revalidate({
id: personWithUserIdAttribute.id,
environmentId: personWithUserIdAttribute.environmentId,
userId,
});
return 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}`],
{
@@ -316,9 +361,6 @@ export const getOrCreatePersonByUserId = async (userId: string, environmentId: s
}
)();
return transformPrismaPerson(personPrisma);
};
export const updatePersonAttribute = async (
personId: string,
attributeClassId: string,

View File

@@ -38,6 +38,7 @@ const responseSelection = {
person: {
select: {
id: true,
userId: true,
createdAt: true,
updatedAt: true,
environmentId: true,

View File

@@ -4,6 +4,7 @@ import SurveyState from "./surveyState";
interface QueueConfig {
apiHost: string;
environmentId: string;
retryAttempts: number;
onResponseSendingFailed?: (responseUpdate: TResponseUpdate) => void;
setSurveyState?: (state: SurveyState) => void;
@@ -21,7 +22,7 @@ export class ResponseQueue {
this.surveyState = surveyState;
this.api = new FormbricksAPI({
apiHost: config.apiHost,
environmentId: "",
environmentId: config.environmentId,
});
}

View File

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

View File

@@ -1,145 +0,0 @@
"use server";
import "server-only";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError } from "@formbricks/types/errors";
import { TSession } from "@formbricks/types/sessions";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { validateInputs } from "../utils/validate";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { sessionCache } from "./cache";
import { formatSessionDateFields } from "./util";
const select = {
id: true,
createdAt: true,
updatedAt: true,
expiresAt: true,
personId: true,
};
const oneHour = 1000 * 60 * 60;
export const getSession = async (sessionId: string): Promise<TSession | null> => {
const session = await unstable_cache(
async () => {
validateInputs([sessionId, ZId]);
try {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
},
select,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSession-${sessionId}`],
{
tags: [sessionCache.tag.byId(sessionId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!session) return null;
return formatSessionDateFields(session);
};
export const getSessionCount = async (personId: string): Promise<number> =>
unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const sessionCount = await prisma.session.count({
where: {
personId,
},
});
return sessionCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSessionCount-${personId}`],
{
tags: [sessionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const createSession = async (personId: string): Promise<TSession> => {
validateInputs([personId, ZId]);
try {
const session = await prisma.session.create({
data: {
person: {
connect: {
id: personId,
},
},
expiresAt: new Date(Date.now() + oneHour),
},
select,
});
if (session) {
sessionCache.revalidate({
id: session.id,
personId,
});
}
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const extendSession = async (sessionId: string): Promise<TSession> => {
validateInputs([sessionId, ZId]);
try {
const session = await prisma.session.update({
where: {
id: sessionId,
},
data: {
expiresAt: new Date(Date.now() + oneHour),
},
select,
});
// revalidate session cache
sessionCache.revalidate({
id: sessionId,
personId: session.personId,
});
return session;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

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

View File

@@ -15,6 +15,14 @@ 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";
export const selectSurvey = {
id: true,
@@ -42,7 +50,7 @@ export const selectSurvey = {
pin: true,
triggers: {
select: {
eventClass: {
actionClass: {
select: {
id: true,
createdAt: true,
@@ -115,7 +123,7 @@ export const getSurvey = async (surveyId: string): Promise<TSurvey | null> => {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
};
return transformedSurvey;
@@ -165,7 +173,7 @@ export const getSurveysByAttributeClassId = async (
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
};
surveys.push(transformedSurvey);
}
@@ -194,7 +202,7 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
where: {
triggers: {
some: {
eventClass: {
actionClass: {
id: actionClassId,
},
},
@@ -210,7 +218,7 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
};
surveys.push(transformedSurvey);
}
@@ -258,7 +266,7 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass.name),
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
};
surveys.push(transformedSurvey);
}
@@ -323,7 +331,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
data.triggers = {
...(data.triggers || []),
create: newTriggers.map((trigger) => ({
eventClassId: getActionClassIdFromName(actionClasses, trigger),
actionClassId: getActionClassIdFromName(actionClasses, trigger),
})),
};
}
@@ -332,7 +340,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
data.triggers = {
...(data.triggers || []),
deleteMany: {
eventClassId: {
actionClassId: {
in: removedTriggers.map((trigger) => getActionClassIdFromName(actionClasses, trigger)),
},
},
@@ -473,7 +481,7 @@ export async function deleteSurvey(surveyId: string) {
// Revalidate triggers by actionClassId
deletedSurvey.triggers.forEach((trigger) => {
surveyCache.revalidate({
actionClassId: trigger.eventClass.id,
actionClassId: trigger.actionClass.id,
});
});
// Revalidate surveys by attributeClassId
@@ -519,7 +527,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
const transformedSurvey = {
...survey,
triggers: survey.triggers.map((trigger) => trigger.eventClass.name),
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
};
captureTelemetry("survey created");
@@ -558,7 +566,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: existingSurvey.triggers.map((trigger) => ({
eventClassId: getActionClassIdFromName(actionClasses, trigger),
actionClassId: getActionClassIdFromName(actionClasses, trigger),
})),
},
attributeFilters: {
@@ -597,3 +605,102 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
return newSurvey;
};
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

@@ -306,7 +306,7 @@ export const getMonthlyActiveTeamPeopleCount = async (teamId: string): Promise<n
AND: [
{ environmentId: { in: environmentIds } },
{
sessions: {
actions: {
some: {
createdAt: { gte: firstDayOfMonth },
},

View File

@@ -0,0 +1,7 @@
// utility functions for date and time
// Helper function to calculate difference in days between two dates
export const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};

View File

@@ -4,7 +4,7 @@ import { ZActionClass } from "./actionClasses";
export const ZAction = z.object({
id: z.string(),
createdAt: z.date(),
sessionId: z.string(),
personId: z.string(),
properties: z.record(z.string()),
actionClass: ZActionClass.nullable(),
});
@@ -12,10 +12,20 @@ export const ZAction = z.object({
export type TAction = z.infer<typeof ZAction>;
export const ZActionInput = z.object({
environmentId: z.string().cuid2(),
sessionId: z.string().cuid2(),
environmentId: z.string().cuid(),
userId: z.string(),
name: z.string(),
properties: z.record(z.string()),
});
export type TActionInput = z.infer<typeof ZActionInput>;
export const ZActionLegacyInput = z.object({
environmentId: z.string().cuid2(),
personId: z.string().optional(),
sessionId: z.string().optional(),
name: z.string(),
properties: z.record(z.string()),
});
export type TActionLegacyInput = z.infer<typeof ZActionLegacyInput>;

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