From 53ef8771f39410406c073a64da206074e9111ad3 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Sun, 12 Nov 2023 14:42:58 +0530 Subject: [PATCH 1/2] feat: Make formbricks-js ready for public websites (#1470) Co-authored-by: Matthias Nannt Co-authored-by: Johannes --- CODE_OF_CONDUCT.md | 3 +- CONTRIBUTING.md | 7 +- apps/demo/pages/_app.tsx | 30 -- apps/demo/pages/app/index.tsx | 64 ++++- .../app/docs/actions/code/page.mdx | 5 +- .../docs/attributes/identify-users/page.mdx | 20 +- .../app/docs/contributing/demo/page.mdx | 17 +- .../getting-started/framework-guides/page.mdx | 2 +- .../quickstart-in-app-survey/page.mdx | 2 + .../components/dummyUI/AddEventDummy.tsx | 14 +- .../components/home/SetupTabs.tsx | 2 +- .../pages/api/oss-friends/index.ts | 3 +- .../app/(app)/components/FormbricksClient.tsx | 2 +- .../actions/components/ActionClassesTable.tsx | 2 +- .../actions/components/AddActionModal.tsx | 270 ++++++++++++++++++ .../components/AddNoCodeActionModal.tsx | 207 -------------- .../environments/[environmentId]/actions.ts | 22 +- .../components/WidgetStatusIndicator.tsx | 56 +--- .../components/AttributesSection.tsx | 9 +- .../people/[personId]/loading.tsx | 6 +- .../[environmentId]/people/page.tsx | 2 +- .../setup/components/SetupInstructions.tsx | 2 +- .../edit/components/HowToSendCard.tsx | 4 +- .../edit/components/WhenToSendCard.tsx | 2 +- .../edit/components/WhoToSendCard.tsx | 20 ++ .../app/api/v1/(legacy)/js/actions/route.ts | 10 + .../web/app/api/v1/(legacy)/js/lib/surveys.ts | 116 ++++++++ apps/web/app/api/v1/(legacy)/js/lib/sync.ts | 133 +++++++++ .../people/[personId]/set-attribute/route.ts | 74 +++++ .../js/people/[personId]/set-user-id/route.ts | 38 +++ .../app/api/v1/(legacy)/js/people/route.ts | 32 +++ .../v1/{ => (legacy)}/js/sync/lib/surveys.ts | 0 .../api/v1/{ => (legacy)}/js/sync/lib/sync.ts | 93 ++---- .../api/v1/{ => (legacy)}/js/sync/route.ts | 10 +- .../displays/[displayId]/responded/route.ts | 0 .../displays/[displayId]/route.ts | 0 .../client/{ => (legacy)}/displays/route.ts | 27 +- .../responses/[responseId]/route.ts | 0 .../api/v1/client/(legacy)/responses/route.ts | 109 +++++++ .../[environmentId]}/actions/route.ts | 27 +- .../displays/[displayId]/route.ts | 40 +++ .../client/[environmentId]/displays/route.ts | 65 +++++ .../in-app/sync/[userId]/route.ts | 113 ++++++++ .../[environmentId]/in-app/sync/route.ts | 66 +++++ .../people/[personId]/set-attribute/route.ts | 17 +- .../v1/client/[environmentId]/people/route.ts | 32 +++ .../responses/[responseId]/route.ts | 87 ++++++ .../{ => [environmentId]}/responses/route.ts | 12 +- .../storage/lib/uploadPrivateFile.ts | 0 .../storage/local/route.ts | 16 +- .../{ => [environmentId]}/storage/route.ts | 16 +- .../[environmentId]/people/route.ts | 43 --- .../js/people/[personId]/set-user-id/route.ts | 122 -------- apps/web/app/lib/api/clientPerson.ts | 44 --- apps/web/app/lib/api/clientSession.ts | 17 -- apps/web/app/lib/api/clientSettings.ts | 6 +- apps/web/app/lib/formbricks.ts | 3 +- .../s/[surveyId]/components/LinkSurvey.tsx | 1 + .../[environmentId]/events/index.ts | 14 +- .../people/[personId]/user-id.ts | 143 ---------- .../responses/[responseId]/index.ts | 19 +- .../[environmentId]/responses/index.ts | 19 +- .../[environmentId]/sessions/index.ts | 38 --- packages/api/src/api/client/display.ts | 17 +- packages/api/src/api/client/index.ts | 6 +- packages/api/src/api/client/response.ts | 8 +- packages/database/jsonTypes.ts | 4 +- .../migration.sql | 82 ++++++ packages/database/schema.prisma | 67 ++--- packages/database/zod-utils.ts | 2 +- packages/js/index.html | 2 +- packages/js/package.json | 2 +- packages/js/src/index.ts | 4 +- packages/js/src/lib/actions.ts | 12 +- packages/js/src/lib/config.ts | 14 +- packages/js/src/lib/eventListeners.ts | 12 +- packages/js/src/lib/initialize.ts | 53 ++-- packages/js/src/lib/person.ts | 84 +----- packages/js/src/lib/session.ts | 6 - packages/js/src/lib/sync.ts | 153 +++++++--- packages/js/src/lib/widget.ts | 67 ++++- packages/js/tests/__mocks__/apiMock.ts | 17 +- packages/js/tests/index.test.ts | 15 +- packages/lib/action/cache.ts | 5 +- packages/lib/action/service.ts | 79 ++--- packages/lib/actionClass/auth.ts | 1 + packages/lib/actionClass/service.ts | 12 +- packages/lib/authOptions.ts | 1 + packages/lib/display/service.ts | 45 +++ packages/lib/environment/service.ts | 2 +- packages/lib/person/service.ts | 138 +++++---- packages/lib/response/service.ts | 1 + packages/lib/responseQueue.ts | 3 +- packages/lib/session/cache.ts | 26 -- packages/lib/session/service.ts | 145 ---------- packages/lib/session/util.ts | 17 -- packages/lib/survey/service.ts | 129 ++++++++- packages/lib/team/service.ts | 2 +- packages/lib/utils/datetime.ts | 7 + packages/types/actions.ts | 16 +- packages/types/displays.ts | 27 +- packages/types/js.ts | 80 ++++-- packages/types/people.ts | 1 + packages/types/sessions.ts | 30 -- packages/ui/Alert/index.tsx | 1 + 105 files changed, 2262 insertions(+), 1508 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal.tsx delete mode 100644 apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal.tsx create mode 100644 apps/web/app/api/v1/(legacy)/js/actions/route.ts create mode 100644 apps/web/app/api/v1/(legacy)/js/lib/surveys.ts create mode 100644 apps/web/app/api/v1/(legacy)/js/lib/sync.ts create mode 100644 apps/web/app/api/v1/(legacy)/js/people/[personId]/set-attribute/route.ts create mode 100644 apps/web/app/api/v1/(legacy)/js/people/[personId]/set-user-id/route.ts create mode 100644 apps/web/app/api/v1/(legacy)/js/people/route.ts rename apps/web/app/api/v1/{ => (legacy)}/js/sync/lib/surveys.ts (100%) rename apps/web/app/api/v1/{ => (legacy)}/js/sync/lib/sync.ts (52%) rename apps/web/app/api/v1/{ => (legacy)}/js/sync/route.ts (70%) rename apps/web/app/api/v1/client/{ => (legacy)}/displays/[displayId]/responded/route.ts (100%) rename apps/web/app/api/v1/client/{ => (legacy)}/displays/[displayId]/route.ts (100%) rename apps/web/app/api/v1/client/{ => (legacy)}/displays/route.ts (76%) rename apps/web/app/api/v1/client/{ => (legacy)}/responses/[responseId]/route.ts (100%) create mode 100644 apps/web/app/api/v1/client/(legacy)/responses/route.ts rename apps/web/app/api/v1/{js => client/[environmentId]}/actions/route.ts (64%) create mode 100644 apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/displays/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts rename apps/web/app/api/v1/{js => client/[environmentId]}/people/[personId]/set-attribute/route.ts (82%) create mode 100644 apps/web/app/api/v1/client/[environmentId]/people/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts rename apps/web/app/api/v1/client/{ => [environmentId]}/responses/route.ts (94%) rename apps/web/app/api/v1/client/{ => [environmentId]}/storage/lib/uploadPrivateFile.ts (100%) rename apps/web/app/api/v1/client/{ => [environmentId]}/storage/local/route.ts (90%) rename apps/web/app/api/v1/client/{ => [environmentId]}/storage/route.ts (81%) delete mode 100644 apps/web/app/api/v1/client/environments/[environmentId]/people/route.ts delete mode 100644 apps/web/app/api/v1/js/people/[personId]/set-user-id/route.ts delete mode 100644 apps/web/app/lib/api/clientPerson.ts delete mode 100644 apps/web/app/lib/api/clientSession.ts delete mode 100644 apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts delete mode 100644 apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts create mode 100644 packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql delete mode 100644 packages/js/src/lib/session.ts delete mode 100644 packages/lib/session/cache.ts delete mode 100644 packages/lib/session/service.ts delete mode 100644 packages/lib/session/util.ts create mode 100644 packages/lib/utils/datetime.ts delete mode 100644 packages/types/sessions.ts diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 06deb25042..49d10baaac 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3a2aac4c0..403cef9117 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/apps/demo/pages/_app.tsx b/apps/demo/pages/_app.tsx index 08ab48b9e2..6b12fe2353 100644 --- a/apps/demo/pages/_app.tsx +++ b/apps/demo/pages/_app.tsx @@ -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 ( <> diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx index 0cf73d62e0..66c50d496c 100644 --- a/apps/demo/pages/app/index.tsx +++ b/apps/demo/pages/app/index.tsx @@ -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 (
@@ -204,25 +232,37 @@ export default function AppPage({}) {
-
- -
+ {router.query.userId === "true" ? ( +
+ +
+ ) : ( +
+ +
+ )}

- This button sets an external{" "} + This button activates/deactivates{" "} - user ID + user identification {" "} - to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' + with the userId 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'

diff --git a/apps/formbricks-com/app/docs/actions/code/page.mdx b/apps/formbricks-com/app/docs/actions/code/page.mdx index 6b69349fe2..058988349d 100644 --- a/apps/formbricks-com/app/docs/actions/code/page.mdx +++ b/apps/formbricks-com/app/docs/actions/code/page.mdx @@ -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()` + diff --git a/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx b/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx index 686544dfa5..0f923cc1f1 100644 --- a/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx +++ b/apps/formbricks-com/app/docs/attributes/identify-users/page.mdx @@ -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. + ```javascript -formbricks.setUserId("USER_ID"); +formbricks.init({ + environmentId: "", + apiHost: "", + userID: "", +}); ``` ## 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: + @@ -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.): + ```javascript -formbricks.setAttribute("attribute_key", "attribute_value"); +formbricks.setAttribute("Plan", "free"); ``` @@ -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: + @@ -59,4 +67,4 @@ formbricks.logout(); ``` - \ No newline at end of file + diff --git a/apps/formbricks-com/app/docs/contributing/demo/page.mdx b/apps/formbricks-com/app/docs/contributing/demo/page.mdx index 192773d967..b91ce0f802 100644 --- a/apps/formbricks-com/app/docs/contributing/demo/page.mdx +++ b/apps/formbricks-com/app/docs/contributing/demo/page.mdx @@ -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). -Demo App Preview +Demo App Preview ## Functionality ### Code Action This button sends a Code Action to the Formbricks API called 'Code Action'. You will find it in the Actions Tab. + @@ -32,6 +34,7 @@ formbricks.track("Code Action"); ### No Code Action This button sends a No Code Action 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. + @@ -44,6 +47,7 @@ This button sends a No Code Action as long a ### Set Plan to "Free" This button sets the attribute 'Plan' to 'Free'. If the attribute does not exist, it creates it. + @@ -56,6 +60,7 @@ formbricks.setAttribute("Plan", "Free"); ### Set Plan to "Paid" This button sets the attribute 'Plan' to 'Paid'. If the attribute does not exist, it creates it. + @@ -68,6 +73,7 @@ formbricks.setAttribute("Plan", "Paid"); ### Set Email This button sets the user email 'test@web.com' + @@ -79,13 +85,14 @@ formbricks.setEmail("test@web.com"); ### Set UserId -This button sets an external user ID to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' +This button sets an external user ID in the Formbricks init call to 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING' + ```tsx -formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"); +userId: "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"; ``` - \ No newline at end of file + diff --git a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx index 6e62bc5a3e..4f60685ddc 100644 --- a/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx +++ b/apps/formbricks-com/app/docs/getting-started/framework-guides/page.mdx @@ -45,7 +45,7 @@ All you need to do is copy a ` ``` diff --git a/apps/formbricks-com/app/docs/getting-started/quickstart-in-app-survey/page.mdx b/apps/formbricks-com/app/docs/getting-started/quickstart-in-app-survey/page.mdx index 9347068cb9..3fde98feda 100644 --- a/apps/formbricks-com/app/docs/getting-started/quickstart-in-app-survey/page.mdx +++ b/apps/formbricks-com/app/docs/getting-started/quickstart-in-app-survey/page.mdx @@ -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: diff --git a/apps/formbricks-com/components/dummyUI/AddEventDummy.tsx b/apps/formbricks-com/components/dummyUI/AddEventDummy.tsx index 32cd25920f..827d6bdf5d 100644 --- a/apps/formbricks-com/components/dummyUI/AddEventDummy.tsx +++ b/apps/formbricks-com/components/dummyUI/AddEventDummy.tsx @@ -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([eventClasses[0].id]); + const [triggers, setTriggers] = useState([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 = () => { - {eventClasses.map((eventClass) => ( + {actionClasses.map((actionClass) => ( - {eventClass.name} + value={actionClass.id}> + {actionClass.name} ))} diff --git a/apps/formbricks-com/components/home/SetupTabs.tsx b/apps/formbricks-com/components/home/SetupTabs.tsx index 216b1d1feb..eb6f6402ba 100644 --- a/apps/formbricks-com/components/home/SetupTabs.tsx +++ b/apps/formbricks-com/components/home/SetupTabs.tsx @@ -60,7 +60,7 @@ if (typeof window !== "undefined") { ) : activeTab === "html" ? ( {``} ) : null} diff --git a/apps/formbricks-com/pages/api/oss-friends/index.ts b/apps/formbricks-com/pages/api/oss-friends/index.ts index 4dfc8c558d..57661b4750 100644 --- a/apps/formbricks-com/pages/api/oss-friends/index.ts +++ b/apps/formbricks-com/pages/api/oss-friends/index.ts @@ -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", }, { diff --git a/apps/web/app/(app)/components/FormbricksClient.tsx b/apps/web/app/(app)/components/FormbricksClient.tsx index 6db17d27b8..fdc54de3b4 100644 --- a/apps/web/app/(app)/components/FormbricksClient.tsx +++ b/apps/web/app/(app)/components/FormbricksClient.tsx @@ -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]); diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/ActionClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/ActionClassesTable.tsx index 48fdf68ec7..eb586e8a4c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/ActionClassesTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/ActionClassesTable.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal.tsx new file mode 100644 index 0000000000..8f0db6de35 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddActionModal.tsx @@ -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): Promise => { + 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 ( + resetAllStates(false)} noPadding closeOnOutsideClick={false}> +
+
+
+
+
+ +
+
+
Track New User Action
+
+ Track a user action to display surveys or create user segment. +
+
+
+
+
+ + {type === "noCode" ? ( +
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+
+
+ + +
+
+
+ ) : ( +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + How do Code Actions work? + + You can track code action anywhere in your app using{" "} + + formbricks.track("{watch("name")}") + {" "} + in your code. Read more in our{" "} + + docs + + . + + +
+
+
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal.tsx deleted file mode 100644 index 93bd18c079..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/components/AddNoCodeActionModal.tsx +++ /dev/null @@ -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): Promise => { - 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 ( - resetAllStates(false)} noPadding closeOnOutsideClick={false}> -
-
-
-
-
- -
-
-
Track New User Action
-
- Track a user action to display surveys or create user segment. -
-
-
-
-
-
-
-
-
-
- - -
-
- - -
-
-
- -
- - - -
-
-
-
- - -
-
-
-
-
- ); -} diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 802c95d84d..51bb5e34aa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -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: { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx index 9111def780..3f3288d807 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx @@ -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" )}>

{currentStatus.title}

-

- {currentStatus.subtitle}{" "} - {latestAction && {timeSince(latestAction.createdAt.toISOString())}} -

+

{currentStatus.subtitle}

); } @@ -84,16 +62,12 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
-

- {currentStatus.subtitle}{" "} - {latestAction && {timeSince(latestAction.createdAt.toISOString())}} -

+

{currentStatus.subtitle}

diff --git a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/components/AttributesSection.tsx b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/components/AttributesSection.tsx index 4755672cf7..5e91ee1c82 100644 --- a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/components/AttributesSection.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/components/AttributesSection.tsx @@ -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
{person.attributes.userId ? ( {person.attributes.userId} + ) : person.userId ? ( + {person.userId} ) : ( Not provided )} @@ -56,8 +57,8 @@ export default async function AttributesSection({ personId }: { personId: string
-
Sessions
-
{numberOfSessions}
+ {/*
Sessions
*/} + {/*
{numberOfSessions}
*/}
Responses
diff --git a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/loading.tsx index d3be35753a..b867f37007 100644 --- a/apps/web/app/(app)/environments/[environmentId]/people/[personId]/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/people/[personId]/loading.tsx @@ -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", diff --git a/apps/web/app/(app)/environments/[environmentId]/people/page.tsx b/apps/web/app/(app)/environments/[environmentId]/people/page.tsx index 7df24e86c1..69b82ab6be 100644 --- a/apps/web/app/(app)/environments/[environmentId]/people/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/people/page.tsx @@ -80,7 +80,7 @@ export default async function PeoplePage({
- {truncateMiddle(getAttributeValue(person, "userId"), 24)} + {truncateMiddle(getAttributeValue(person, "userId"), 24) || person.userId}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/setup/components/SetupInstructions.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/setup/components/SetupInstructions.tsx index 03880784ec..5a7f420421 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/setup/components/SetupInstructions.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/setup/components/SetupInstructions.tsx @@ -108,7 +108,7 @@ if (typeof window !== "undefined") {

{` `}

You're done πŸŽ‰

diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx index b76a7fc634..083d882db2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HowToSendCard.tsx @@ -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, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx index 774af989ec..d732b885cc 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhenToSendCard.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx index 71e054a0af..ad27ebefb5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/WhoToSendCard.tsx @@ -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
+
+ + + User Identification + + To target your audience you need to identify your users within your app. You can read more + about how to do this in our{" "} + + docs + + . + + +
+
{localSurvey.attributeFilters?.length === 0 ? ( diff --git a/apps/web/app/api/v1/(legacy)/js/actions/route.ts b/apps/web/app/api/v1/(legacy)/js/actions/route.ts new file mode 100644 index 0000000000..91d1dbdc2b --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/actions/route.ts @@ -0,0 +1,10 @@ +import { responses } from "@/app/lib/api/response"; +import { NextResponse } from "next/server"; + +export async function OPTIONS(): Promise { + return responses.successResponse({}, true); +} + +export async function POST(): Promise { + return responses.successResponse({}, true); +} diff --git a/apps/web/app/api/v1/(legacy)/js/lib/surveys.ts b/apps/web/app/api/v1/(legacy)/js/lib/surveys.ts new file mode 100644 index 0000000000..d27f299460 --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/lib/surveys.ts @@ -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 => { + // 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; +}; diff --git a/apps/web/app/api/v1/(legacy)/js/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/lib/sync.ts new file mode 100644 index 0000000000..f4e5101c09 --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/lib/sync.ts @@ -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 => { + await captureTelemetry("state update", { jsVersion: jsVersion ?? "unknown" }); +}; + +export const getUpdatedState = async ( + environmentId: string, + personId: string, + jsVersion?: string +): Promise => { + 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; +}; diff --git a/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-attribute/route.ts b/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-attribute/route.ts new file mode 100644 index 0000000000..931299e81d --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-attribute/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function POST(req: Request, { params }): Promise { + 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); + } +} diff --git a/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-user-id/route.ts b/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-user-id/route.ts new file mode 100644 index 0000000000..9a7d932e0f --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/people/[personId]/set-user-id/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function POST(req: Request): Promise { + 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); + } +} diff --git a/apps/web/app/api/v1/(legacy)/js/people/route.ts b/apps/web/app/api/v1/(legacy)/js/people/route.ts new file mode 100644 index 0000000000..6bfcdc993a --- /dev/null +++ b/apps/web/app/api/v1/(legacy)/js/people/route.ts @@ -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); + } +} diff --git a/apps/web/app/api/v1/js/sync/lib/surveys.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/surveys.ts similarity index 100% rename from apps/web/app/api/v1/js/sync/lib/surveys.ts rename to apps/web/app/api/v1/(legacy)/js/sync/lib/surveys.ts diff --git a/apps/web/app/api/v1/js/sync/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts similarity index 52% rename from apps/web/app/api/v1/js/sync/lib/sync.ts rename to apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts index 1181229377..2d834d0520 100644 --- a/apps/web/app/api/v1/js/sync/lib/sync.ts +++ b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts @@ -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 => { - await captureTelemetry("session created", { jsVersion: jsVersion ?? "unknown" }); -}; - -export const getUpdatedState = async ( - environmentId: string, - personId?: string, - sessionId?: string, - jsVersion?: string -): Promise => { +export const getUpdatedState = async (environmentId: string, personId?: string): Promise => { 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"), diff --git a/apps/web/app/api/v1/js/sync/route.ts b/apps/web/app/api/v1/(legacy)/js/sync/route.ts similarity index 70% rename from apps/web/app/api/v1/js/sync/route.ts rename to apps/web/app/api/v1/(legacy)/js/sync/route.ts index 7da235a2fe..22602b8607 100644 --- a/apps/web/app/api/v1/js/sync/route.ts +++ b/apps/web/app/api/v1/(legacy)/js/sync/route.ts @@ -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 { @@ -13,7 +13,7 @@ export async function POST(req: Request): Promise { 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 { ); } - 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) { diff --git a/apps/web/app/api/v1/client/displays/[displayId]/responded/route.ts b/apps/web/app/api/v1/client/(legacy)/displays/[displayId]/responded/route.ts similarity index 100% rename from apps/web/app/api/v1/client/displays/[displayId]/responded/route.ts rename to apps/web/app/api/v1/client/(legacy)/displays/[displayId]/responded/route.ts diff --git a/apps/web/app/api/v1/client/displays/[displayId]/route.ts b/apps/web/app/api/v1/client/(legacy)/displays/[displayId]/route.ts similarity index 100% rename from apps/web/app/api/v1/client/displays/[displayId]/route.ts rename to apps/web/app/api/v1/client/(legacy)/displays/[displayId]/route.ts diff --git a/apps/web/app/api/v1/client/displays/route.ts b/apps/web/app/api/v1/client/(legacy)/displays/route.ts similarity index 76% rename from apps/web/app/api/v1/client/displays/route.ts rename to apps/web/app/api/v1/client/(legacy)/displays/route.ts index 5225f40fb9..eef6819cbc 100644 --- a/apps/web/app/api/v1/client/displays/route.ts +++ b/apps/web/app/api/v1/client/(legacy)/displays/route.ts @@ -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 { @@ -13,8 +13,11 @@ export async function OPTIONS(): Promise { } export async function POST(request: Request): Promise { - 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 { ); } - 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 { // 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 { 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"); diff --git a/apps/web/app/api/v1/client/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/(legacy)/responses/[responseId]/route.ts similarity index 100% rename from apps/web/app/api/v1/client/responses/[responseId]/route.ts rename to apps/web/app/api/v1/client/(legacy)/responses/[responseId]/route.ts diff --git a/apps/web/app/api/v1/client/(legacy)/responses/route.ts b/apps/web/app/api/v1/client/(legacy)/responses/route.ts new file mode 100644 index 0000000000..69cc39c300 --- /dev/null +++ b/apps/web/app/api/v1/client/(legacy)/responses/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function POST(request: Request): Promise { + 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); +} diff --git a/apps/web/app/api/v1/js/actions/route.ts b/apps/web/app/api/v1/client/[environmentId]/actions/route.ts similarity index 64% rename from apps/web/app/api/v1/js/actions/route.ts rename to apps/web/app/api/v1/client/[environmentId]/actions/route.ts index 0e0fbf7391..35883bc7a4 100644 --- a/apps/web/app/api/v1/js/actions/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/actions/route.ts @@ -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 { return responses.successResponse({}, true); } -export async function POST(req: Request): Promise { +export async function POST(req: Request, context: Context): Promise { 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 { ); } - 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) { diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts new file mode 100644 index 0000000000..bed7f341ae --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function PUT(request: Request, context: Context): Promise { + 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); + } +} diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/route.ts b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts new file mode 100644 index 0000000000..15927e015e --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function POST(request: Request, context: Context): Promise { + 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 + ); +} diff --git a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts new file mode 100644 index 0000000000..5cbfe6a72a --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function GET( + _: Request, + { + params, + }: { + params: { + environmentId: string; + userId: string; + }; + } +): Promise { + 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); + } +} diff --git a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts new file mode 100644 index 0000000000..078107b79b --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function GET( + _: NextRequest, + { params }: { params: { environmentId: string } } +): Promise { + 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); + } +} diff --git a/apps/web/app/api/v1/js/people/[personId]/set-attribute/route.ts b/apps/web/app/api/v1/client/[environmentId]/people/[personId]/set-attribute/route.ts similarity index 82% rename from apps/web/app/api/v1/js/people/[personId]/set-attribute/route.ts rename to apps/web/app/api/v1/client/[environmentId]/people/[personId]/set-attribute/route.ts index 2aef228c59..418f1d68ce 100644 --- a/apps/web/app/api/v1/js/people/[personId]/set-attribute/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/people/[personId]/set-attribute/route.ts @@ -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 { return responses.successResponse({}, true); } -export async function POST(req: Request, { params }): Promise { +export async function POST(req: Request, context: Context): Promise { 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 { ); } - 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 { environmentId, }); - const state = await getUpdatedState(environmentId, personId, sessionId); + const state = await getUpdatedState(environmentId, personId); return responses.successResponse({ ...state }, true); } catch (error) { diff --git a/apps/web/app/api/v1/client/[environmentId]/people/route.ts b/apps/web/app/api/v1/client/[environmentId]/people/route.ts new file mode 100644 index 0000000000..6bfcdc993a --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/people/route.ts @@ -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); + } +} diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts new file mode 100644 index 0000000000..e59bf5c9cc --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts @@ -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 { + return responses.successResponse({}, true); +} + +export async function PUT( + request: Request, + { params }: { params: { responseId: string } } +): Promise { + 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); +} diff --git a/apps/web/app/api/v1/client/responses/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts similarity index 94% rename from apps/web/app/api/v1/client/responses/route.ts rename to apps/web/app/api/v1/client/[environmentId]/responses/route.ts index 3fa79addbe..c8425c2b39 100644 --- a/apps/web/app/api/v1/client/responses/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts @@ -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 { return responses.successResponse({}, true); @@ -28,13 +27,10 @@ export async function POST(request: Request): Promise { ); } - 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 { }, }; + // check if personId is anonymous + if (responseInput.personId === "anonymous") { + // remove this from the request + responseInput.personId = null; + } + response = await createResponse({ ...responseInput, meta, diff --git a/apps/web/app/api/v1/client/storage/lib/uploadPrivateFile.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts similarity index 100% rename from apps/web/app/api/v1/client/storage/lib/uploadPrivateFile.ts rename to apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts diff --git a/apps/web/app/api/v1/client/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts similarity index 90% rename from apps/web/app/api/v1/client/storage/local/route.ts rename to apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts index 45c757f391..1e60f7c520 100644 --- a/apps/web/app/api/v1/client/storage/local/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts @@ -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 { +interface Context { + params: { + environmentId: string; + }; +} + +export async function POST(req: NextRequest, context: Context): Promise { + 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 { 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); } diff --git a/apps/web/app/api/v1/client/storage/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts similarity index 81% rename from apps/web/app/api/v1/client/storage/route.ts rename to apps/web/app/api/v1/client/[environmentId]/storage/route.ts index 2691616011..3f71ecfc82 100644 --- a/apps/web/app/api/v1/client/storage/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts @@ -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 { +export async function POST(req: NextRequest, context: Context): Promise { + 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 { 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); } diff --git a/apps/web/app/api/v1/client/environments/[environmentId]/people/route.ts b/apps/web/app/api/v1/client/environments/[environmentId]/people/route.ts deleted file mode 100644 index 79149d8ede..0000000000 --- a/apps/web/app/api/v1/client/environments/[environmentId]/people/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST( - _: Request, - { params }: { params: { environmentId: string } } -): Promise { - 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); - } -} diff --git a/apps/web/app/api/v1/js/people/[personId]/set-user-id/route.ts b/apps/web/app/api/v1/js/people/[personId]/set-user-id/route.ts deleted file mode 100644 index f4514c4a09..0000000000 --- a/apps/web/app/api/v1/js/people/[personId]/set-user-id/route.ts +++ /dev/null @@ -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 { - return responses.successResponse({}, true); -} - -export async function POST(req: Request, { params }): Promise { - 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); - } -} diff --git a/apps/web/app/lib/api/clientPerson.ts b/apps/web/app/lib/api/clientPerson.ts deleted file mode 100644 index b846e8b752..0000000000 --- a/apps/web/app/lib/api/clientPerson.ts +++ /dev/null @@ -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 => { - 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; -}; diff --git a/apps/web/app/lib/api/clientSession.ts b/apps/web/app/lib/api/clientSession.ts deleted file mode 100644 index 6e6f173edc..0000000000 --- a/apps/web/app/lib/api/clientSession.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { prisma } from "@formbricks/database"; -import { TSession } from "@formbricks/types/sessions"; - -export const createSession = async (personId: string): Promise> => { - return prisma.session.create({ - data: { - person: { - connect: { - id: personId, - }, - }, - }, - select: { - id: true, - }, - }); -}; diff --git a/apps/web/app/lib/api/clientSettings.ts b/apps/web/app/lib/api/clientSettings.ts index a27990ca9e..ffd84cd9de 100644 --- a/apps/web/app/lib/api/clientSettings.ts +++ b/apps/web/app/lib/api/clientSettings.ts @@ -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", diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts index 24a26cad37..e56580f8a8 100644 --- a/apps/web/app/lib/formbricks.ts +++ b/apps/web/app/lib/formbricks.ts @@ -11,9 +11,10 @@ export const createResponse = async ( ): Promise => { const api = formbricks.getApi(); const personId = formbricks.getPerson()?.id; + return await api.client.response.create({ surveyId, - personId, + personId: personId ?? "", finished, data, }); diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index 0c6b37bbd3..92b906cbd2 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -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)}`); diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts index 50b77b2d17..9e97d576cc 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/events/index.ts @@ -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: { diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts deleted file mode 100644 index 7932b0007b..0000000000 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/people/[personId]/user-id.ts +++ /dev/null @@ -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.`); - } -} diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index 1602dde092..b05690ca5f 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -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); - - 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, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index f61f69d08b..a0602937fa 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -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); - - 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, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts deleted file mode 100644 index e6e14053b5..0000000000 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/sessions/index.ts +++ /dev/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.`); - } -} diff --git a/packages/api/src/api/client/display.ts b/packages/api/src/api/client/display.ts index 8e16c4ba65..5d7510e25a 100644 --- a/packages/api/src/api/client/display.ts +++ b/packages/api/src/api/client/display.ts @@ -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> { - return makeRequest(this.apiHost, "/api/v1/client/displays", "POST", displayInput); + async create( + displayInput: Omit + ): Promise> { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput); } async update( displayId: string, displayInput: TDisplayUpdateInput ): Promise> { - return makeRequest(this.apiHost, `/api/v1/client/displays/${displayId}`, "PUT", displayInput); + return makeRequest( + this.apiHost, + `/api/v1/client/${this.environmentId}/displays/${displayId}`, + "PUT", + displayInput + ); } } diff --git a/packages/api/src/api/client/index.ts b/packages/api/src/api/client/index.ts index 9df02a5e48..f51842f98d 100644 --- a/packages/api/src/api/client/index.ts +++ b/packages/api/src/api/client/index.ts @@ -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); } } diff --git a/packages/api/src/api/client/response.ts b/packages/api/src/api/client/response.ts index a6ca16fc43..c3a810c3f3 100644 --- a/packages/api/src/api/client/response.ts +++ b/packages/api/src/api/client/response.ts @@ -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> { - 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> { - return makeRequest(this.apiHost, `/api/v1/client/responses/${responseId}`, "PUT", { + return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", { finished, data, }); diff --git a/packages/database/jsonTypes.ts b/packages/database/jsonTypes.ts index b4159cd9f1..73432b42e7 100644 --- a/packages/database/jsonTypes.ts +++ b/packages/database/jsonTypes.ts @@ -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; diff --git a/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql b/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql new file mode 100644 index 0000000000..ec71ceb81a --- /dev/null +++ b/packages/database/migrations/20231109052945_restructure_session_action_person/migration.sql @@ -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"; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index c5f0df69dd..210defb3d4 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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[] diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 6a1275dac3..f147db1cf4 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -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"; diff --git a/packages/js/index.html b/packages/js/index.html index 7838db2db8..3de01e695f 100644 --- a/packages/js/index.html +++ b/packages/js/index.html @@ -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 () { diff --git a/packages/js/package.json b/packages/js/package.json index d93907a575..4db7ddb6de 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -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", diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index a6a68709e7..89dc6ef803 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -19,8 +19,8 @@ const init = async (initConfig: TJsConfigInput) => { await queue.wait(); }; -const setUserId = async (userId: string | number): Promise => { - queue.add(true, setPersonUserId, userId); +const setUserId = async (): Promise => { + queue.add(true, setPersonUserId); await queue.wait(); }; diff --git a/packages/js/src/lib/actions.ts b/packages/js/src/lib/actions.ts index 85597c7443..22b972ac11 100644 --- a/packages/js/src/lib/actions.ts +++ b/packages/js/src/lib/actions.ts @@ -14,13 +14,15 @@ export const trackAction = async ( ): Promise> => { 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; diff --git a/packages/js/src/lib/config.ts b/packages/js/src/lib/config.ts index a08ca95757..d59bd53266 100644 --- a/packages/js/src/lib/config.ts +++ b/packages/js/src/lib/config.ts @@ -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); } } diff --git a/packages/js/src/lib/eventListeners.ts b/packages/js/src/lib/eventListeners.ts index 88979f0da8..22f8631b4e 100644 --- a/packages/js/src/lib/eventListeners.ts +++ b/packages/js/src/lib/eventListeners.ts @@ -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(); diff --git a/packages/js/src/lib/initialize.ts b/packages/js/src/lib/initialize.ts index 39da52325a..fc57399ee9 100644 --- a/packages/js/src/lib/initialize.ts +++ b/packages/js/src/lib/initialize.ts @@ -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; diff --git a/packages/js/src/lib/person.ts b/packages/js/src/lib/person.ts index fa80289f51..e2dd443a56 100644 --- a/packages/js/src/lib/person.ts +++ b/packages/js/src/lib/person.ts @@ -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> => { - 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> => { - 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> => { - 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 +> => { + 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> => { 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> => { } }; -export const getPerson = (): TPerson => { +export const getPerson = (): TPerson | null => { return config.get().state.person; }; diff --git a/packages/js/src/lib/session.ts b/packages/js/src/lib/session.ts deleted file mode 100644 index 669c02da75..0000000000 --- a/packages/js/src/lib/session.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/js/src/lib/sync.ts b/packages/js/src/lib/sync.ts index 94e78ad028..deb23d85f7 100644 --- a/packages/js/src/lib/sync.ts +++ b/packages/js/src/lib/sync.ts @@ -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> => { - 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 => { @@ -50,7 +69,7 @@ export const sync = async (params: TJsSyncParams): Promise => { 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 => { } 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); diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 0a8291b09e..8540943cba 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -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 => { 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) { diff --git a/packages/js/tests/__mocks__/apiMock.ts b/packages/js/tests/__mocks__/apiMock.ts index a65a4e2133..ddbd1ab833 100644 --- a/packages/js/tests/__mocks__/apiMock.ts +++ b/packages/js/tests/__mocks__/apiMock.ts @@ -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, diff --git a/packages/js/tests/index.test.ts b/packages/js/tests/index.test.ts index c9ff9db3fa..6b032880ed 100644 --- a/packages/js/tests/index.test.ts +++ b/packages/js/tests/index.test.ts @@ -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); diff --git a/packages/lib/action/cache.ts b/packages/lib/action/cache.ts index 7d3ad66628..64859901c0 100644 --- a/packages/lib/action/cache.ts +++ b/packages/lib/action/cache.ts @@ -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)); + } }, }; diff --git a/packages/lib/action/service.ts b/packages/lib/action/service.ts index de83bb8565..95fd0b21cf 100644 --- a/packages/lib/action/service.ts +++ b/packages/lib/action/service.ts @@ -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 => { 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 => { 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 => { 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 => { }, }); - 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 => { 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 => { + validateInputs([displayInput, ZDisplayLegacyCreateInput]); try { const display = await prisma.display.create({ data: { diff --git a/packages/lib/environment/service.ts b/packages/lib/environment/service.ts index 2ac44691fc..ea3b313dee 100644 --- a/packages/lib/environment/service.ts +++ b/packages/lib/environment/service.ts @@ -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", diff --git a/packages/lib/person/service.ts b/packages/lib/person/service.ts index 0d6f4f8742..a1c4e49042 100644 --- a/packages/lib/person/service.ts +++ b/packages/lib/person/service.ts @@ -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 => } )(); -export const createPerson = async (environmentId: string): Promise => { +export const createPerson = async (environmentId: string, userId: string): Promise => { validateInputs([environmentId, ZId]); try { @@ -168,6 +171,7 @@ export const createPerson = async (environmentId: string): Promise => { 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 => { +export const getPersonByUserId = async (userId: string, environmentId: string): Promise => { 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 => + 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, diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index db01daef63..8a147d3572 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -38,6 +38,7 @@ const responseSelection = { person: { select: { id: true, + userId: true, createdAt: true, updatedAt: true, environmentId: true, diff --git a/packages/lib/responseQueue.ts b/packages/lib/responseQueue.ts index 54b791d6de..bd38b49565 100644 --- a/packages/lib/responseQueue.ts +++ b/packages/lib/responseQueue.ts @@ -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, }); } diff --git a/packages/lib/session/cache.ts b/packages/lib/session/cache.ts deleted file mode 100644 index ef9438be1e..0000000000 --- a/packages/lib/session/cache.ts +++ /dev/null @@ -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)); - } - }, -}; diff --git a/packages/lib/session/service.ts b/packages/lib/session/service.ts deleted file mode 100644 index 4b49cfd1d1..0000000000 --- a/packages/lib/session/service.ts +++ /dev/null @@ -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 => { - 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 => - 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 => { - 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 => { - 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; - } -}; diff --git a/packages/lib/session/util.ts b/packages/lib/session/util.ts deleted file mode 100644 index 6ba2b18659..0000000000 --- a/packages/lib/session/util.ts +++ /dev/null @@ -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; -}; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 6ac329dc0e..ecce9c713e 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -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 => { 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 => 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 => 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 => { + // 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; +}; diff --git a/packages/lib/team/service.ts b/packages/lib/team/service.ts index c1e88c2703..5591181950 100644 --- a/packages/lib/team/service.ts +++ b/packages/lib/team/service.ts @@ -306,7 +306,7 @@ export const getMonthlyActiveTeamPeopleCount = async (teamId: string): Promise { + const diffTime = Math.abs(date2.getTime() - date1.getTime()); + return Math.floor(diffTime / (1000 * 60 * 60 * 24)); +}; diff --git a/packages/types/actions.ts b/packages/types/actions.ts index 40c48532cb..3ff6b6849b 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -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; 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; + +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; diff --git a/packages/types/displays.ts b/packages/types/displays.ts index af179fd9b4..8d7cf2296c 100644 --- a/packages/types/displays.ts +++ b/packages/types/displays.ts @@ -1,28 +1,37 @@ import { z } from "zod"; export const ZDisplay = z.object({ - id: z.string().cuid2(), + id: z.string().cuid(), createdAt: z.date(), updatedAt: z.date(), - personId: z.string().cuid2().nullable(), - surveyId: z.string().cuid2(), - responseId: z.string().cuid2().nullable(), + personId: z.string().cuid().nullable(), + surveyId: z.string().cuid(), + responseId: z.string().cuid().nullable(), status: z.enum(["seen", "responded"]).optional(), }); export type TDisplay = z.infer; export const ZDisplayCreateInput = z.object({ - surveyId: z.string().cuid2(), - personId: z.string().cuid2().optional(), - responseId: z.string().cuid2().optional(), + environmentId: z.string().cuid(), + surveyId: z.string().cuid(), + userId: z.string().optional(), + responseId: z.string().cuid().optional(), }); export type TDisplayCreateInput = z.infer; +export const ZDisplayLegacyCreateInput = z.object({ + surveyId: z.string().cuid(), + personId: z.string().cuid().optional(), + responseId: z.string().cuid().optional(), +}); + +export type TDisplayLegacyCreateInput = z.infer; + export const ZDisplayUpdateInput = z.object({ - personId: z.string().cuid2().optional(), - responseId: z.string().cuid2().optional(), + personId: z.string().cuid().optional(), + responseId: z.string().cuid().optional(), }); export type TDisplayUpdateInput = z.infer; diff --git a/packages/types/js.ts b/packages/types/js.ts index 9dd69c8942..cbcb581f3d 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -1,6 +1,5 @@ import z from "zod"; import { ZPerson } from "./people"; -import { ZSession } from "./sessions"; import { ZSurvey } from "./surveys"; import { ZActionClass } from "./actionClasses"; import { ZProduct } from "./product"; @@ -11,62 +10,110 @@ const ZSurveyWithTriggers = ZSurvey.extend({ export type TSurveyWithTriggers = z.infer; +export const ZJSStateDisplay = z.object({ + createdAt: z.date(), + surveyId: z.string().cuid(), + responded: z.boolean(), +}); + +export type TJSStateDisplay = z.infer; + export const ZJsState = z.object({ - person: ZPerson, - session: ZSession, + person: ZPerson.nullable(), surveys: z.array(ZSurveyWithTriggers), noCodeActionClasses: z.array(ZActionClass), product: ZProduct, + displays: z.array(ZJSStateDisplay).optional(), }); export type TJsState = z.infer; +export const ZJsLegacyState = z.object({ + person: ZPerson.nullable().or(z.object({})), + session: z.object({}), + surveys: z.array(ZSurveyWithTriggers), + noCodeActionClasses: z.array(ZActionClass), + product: ZProduct, + displays: z.array(ZJSStateDisplay).optional(), +}); + +export type TJsLegacyState = z.infer; + +export const ZJsPublicSyncInput = z.object({ + environmentId: z.string().cuid(), +}); + +export type TJsPublicSyncInput = z.infer; + export const ZJsSyncInput = z.object({ - environmentId: z.string().cuid2(), - personId: z.string().cuid2().optional(), - sessionId: z.string().cuid2().optional(), + environmentId: z.string().cuid(), + userId: z.string().optional().optional(), jsVersion: z.string().optional(), }); export type TJsSyncInput = z.infer; +export const ZJsSyncLegacyInput = z.object({ + environmentId: z.string().cuid(), + personId: z.string().cuid().optional(), + sessionId: z.string().cuid().optional(), + jsVersion: z.string().optional(), +}); + +export type TJsSyncLegacyInput = z.infer; + export const ZJsConfig = z.object({ - environmentId: z.string().cuid2(), + environmentId: z.string().cuid(), apiHost: z.string(), state: ZJsState, + expiresAt: z.date(), }); export type TJsConfig = z.infer; +export const ZJsConfigUpdateInput = z.object({ + environmentId: z.string().cuid(), + apiHost: z.string(), + state: ZJsState, +}); + +export type TJsConfigUpdateInput = z.infer; + export const ZJsConfigInput = z.object({ - environmentId: z.string().cuid2(), + environmentId: z.string().cuid(), apiHost: z.string(), debug: z.boolean().optional(), errorHandler: z.function().args(z.any()).returns(z.void()).optional(), + userId: z.string().optional(), }); export type TJsConfigInput = z.infer; export const ZJsPeopleUserIdInput = z.object({ - environmentId: z.string().cuid2(), + environmentId: z.string().cuid(), userId: z.string().min(1).max(255), - sessionId: z.string().cuid2(), }); export type TJsPeopleUserIdInput = z.infer; export const ZJsPeopleAttributeInput = z.object({ - environmentId: z.string().cuid2(), - sessionId: z.string().cuid2(), key: z.string(), value: z.string(), }); export type TJsPeopleAttributeInput = z.infer; +export const ZJsPeopleLegacyAttributeInput = z.object({ + environmentId: z.string().cuid(), + key: z.string(), + value: z.string(), +}); + +export type TJsPeopleLegacyAttributeInput = z.infer; + export const ZJsActionInput = z.object({ - environmentId: z.string().cuid2(), - sessionId: z.string().cuid2(), + environmentId: z.string().cuid(), + userId: z.string().optional(), name: z.string(), properties: z.record(z.string()), }); @@ -74,10 +121,9 @@ export const ZJsActionInput = z.object({ export type TJsActionInput = z.infer; export const ZJsSyncParams = z.object({ - environmentId: z.string().cuid2(), + environmentId: z.string().cuid(), apiHost: z.string(), - personId: z.string().cuid2().optional(), - sessionId: z.string().cuid2().optional(), + userId: z.string().optional(), }); export type TJsSyncParams = z.infer; diff --git a/packages/types/people.ts b/packages/types/people.ts index 9c78b70fd4..4a00c8c7b1 100644 --- a/packages/types/people.ts +++ b/packages/types/people.ts @@ -5,6 +5,7 @@ export type TPersonAttributes = z.infer; export const ZPerson = z.object({ id: z.string().cuid2(), + userId: z.string(), attributes: ZPersonAttributes, createdAt: z.date(), updatedAt: z.date(), diff --git a/packages/types/sessions.ts b/packages/types/sessions.ts deleted file mode 100644 index 0a51406a3a..0000000000 --- a/packages/types/sessions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import z from "zod"; - -export const ZSession = z.object({ - id: z.string().cuid2(), - createdAt: z.date(), - updatedAt: z.date(), - expiresAt: z.date(), - personId: z.string().cuid2(), -}); - -export type TSession = z.infer; - -export const ZSessionWithActions = z.object({ - id: z.string().cuid2(), - events: z.array( - z.object({ - id: z.string().cuid2(), - createdAt: z.date(), - eventClass: z - .object({ - name: z.string(), - description: z.union([z.string(), z.null()]), - type: z.enum(["code", "noCode", "automatic"]), - }) - .nullable(), - }) - ), -}); - -export type TSessionWithActions = z.infer; diff --git a/packages/ui/Alert/index.tsx b/packages/ui/Alert/index.tsx index 2a4d4fd1c5..545d62c725 100644 --- a/packages/ui/Alert/index.tsx +++ b/packages/ui/Alert/index.tsx @@ -11,6 +11,7 @@ const alertVariants = cva( default: "bg-background text-foreground", destructive: "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", + info: "text-slate-800 bg-brand/5", }, }, defaultVariants: { From 6a40ed705de3a133d710274996ecc6dfa240b724 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:32:08 +0100 Subject: [PATCH 2/2] fix: Update README.md (#1608) --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b00b1f5a8..c9a496ed0b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,4 @@
- -[](https://www.producthunt.com/posts/formbricks) -

Open Source Experience Management Solution Qualtrics Alternative Logo @@ -18,7 +15,7 @@

License Join Formbricks Discord Github Stars Hacker News - Product Hunt + Product Hunt Github Accelerator