mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-16 19:07:16 -06:00
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:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
|
||||
with the userId 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,7 +45,7 @@ All you need to do is copy a `<script>` tag to your HTML head, and that’s 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 -->
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("{watch("name")}")
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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're done 🎉</p>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
10
apps/web/app/api/v1/(legacy)/js/actions/route.ts
Normal file
10
apps/web/app/api/v1/(legacy)/js/actions/route.ts
Normal 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);
|
||||
}
|
||||
116
apps/web/app/api/v1/(legacy)/js/lib/surveys.ts
Normal file
116
apps/web/app/api/v1/(legacy)/js/lib/surveys.ts
Normal 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;
|
||||
};
|
||||
133
apps/web/app/api/v1/(legacy)/js/lib/sync.ts
Normal file
133
apps/web/app/api/v1/(legacy)/js/lib/sync.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
32
apps/web/app/api/v1/(legacy)/js/people/route.ts
Normal file
32
apps/web/app/api/v1/(legacy)/js/people/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -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) {
|
||||
@@ -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");
|
||||
109
apps/web/app/api/v1/client/(legacy)/responses/route.ts
Normal file
109
apps/web/app/api/v1/client/(legacy)/responses/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
65
apps/web/app/api/v1/client/[environmentId]/displays/route.ts
Normal file
65
apps/web/app/api/v1/client/[environmentId]/displays/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
32
apps/web/app/api/v1/client/[environmentId]/people/route.ts
Normal file
32
apps/web/app/api/v1/client/[environmentId]/people/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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[]
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -221,6 +221,7 @@ export const authOptions: NextAuthOptions = {
|
||||
{
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
// @ts-ignore
|
||||
team: {
|
||||
create: {
|
||||
name: `${user.name}'s Team`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ const responseSelection = {
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -306,7 +306,7 @@ export const getMonthlyActiveTeamPeopleCount = async (teamId: string): Promise<n
|
||||
AND: [
|
||||
{ environmentId: { in: environmentIds } },
|
||||
{
|
||||
sessions: {
|
||||
actions: {
|
||||
some: {
|
||||
createdAt: { gte: firstDayOfMonth },
|
||||
},
|
||||
|
||||
7
packages/lib/utils/datetime.ts
Normal file
7
packages/lib/utils/datetime.ts
Normal 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));
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user