Compare commits

..

15 Commits

Author SHA1 Message Date
Matti Nannt
8b77494e32 fix: person update endpoint not working due to caching issue (#1666) 2023-11-21 15:02:01 +00:00
Matti Nannt
9ad5d4ec5c fix: caching issue leads to action endpoint fail for new person (#1665) 2023-11-21 14:35:00 +00:00
Dhruwang Jariwala
1582ac13da fix: Weekly summary endpoint failing (#1440)
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-21 14:00:31 +00:00
Shubham Palriwala
61c7d78612 feat: new client actions endpoint docs (#1659) 2023-11-21 12:55:33 +00:00
Shubham Palriwala
556fe5453c feat: updated client display endpoint docs & update display bug fix (#1654) 2023-11-21 12:35:56 +00:00
Jonas Höbenreich
fd59ec4f8e fix: improve a11y of survey modal close button (#1661)
Co-authored-by: jonas.hoebenreich <jonas.hoebenreich@flixbus.com>
2023-11-21 12:24:51 +00:00
Matti Nannt
6db76d094b chore: move n8n-node to own repository (#1662) 2023-11-21 12:09:53 +00:00
Matti Nannt
94bf1fd6fe fix: close formbricks-js surveys on logout (#1655) 2023-11-21 09:29:31 +00:00
Rohit Mondal
860630dd5a style: fixed color contrast in onboarding page (#1631)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2023-11-21 09:28:03 +00:00
Dhruwang Jariwala
97cc6232c2 fix: page refresh issue on adding new action (#1634) 2023-11-21 08:55:02 +00:00
Matti Nannt
7331d1dd5a chore: update npm dependencies to latest version (#1651) 2023-11-21 08:32:07 +00:00
Matti Nannt
3f8bf4c34c chore: simplify getPersonByUserId by removing legacy person support (#1649) 2023-11-20 21:07:40 +00:00
Matti Nannt
91ceffba01 fix: personByUserId not cached properly (#1644) 2023-11-20 20:05:58 +00:00
Shaik_Asif
8c38495812 fix: typo in template (#1648) 2023-11-20 19:58:05 +00:00
Matti Nannt
c8c98499ed chore: Simplify person service by removing complex getOrCreatePerson function (#1643) 2023-11-20 17:22:11 +00:00
59 changed files with 1160 additions and 3946 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ export default function SurveyEditor({
useEffect(() => {
if (survey) {
if (localSurvey) return;
setLocalSurvey(JSON.parse(JSON.stringify(survey)));
if (survey.questions.length > 0) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,12 @@ import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getOrCreatePersonByUserId, getPersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -72,13 +71,15 @@ export async function GET(
isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
}
let person: TPerson | null;
let person = await getPersonByUserId(environmentId, userId);
if (!isMauLimitReached) {
person = await getOrCreatePersonByUserId(userId, environmentId);
if (!person) {
person = await createPerson(environmentId, userId);
}
} else {
person = await getPersonByUserId(userId, environmentId);
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
if (!person) {
// if it's a new person and MAU limit is reached, throw an error
throw new Error(errorMessage);
} else {
// check if person has been active this month

View File

@@ -1,6 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrCreatePersonByUserId, updatePerson } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId, updatePerson } from "@formbricks/lib/person/service";
import { ZPersonUpdateInput } from "@formbricks/types/people";
import { NextResponse } from "next/server";
@@ -31,15 +31,17 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
);
}
const person = await getOrCreatePersonByUserId(userId, environmentId);
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
return responses.notFoundResponse("PersonByUserId", userId, true);
// return responses.notFoundResponse("PersonByUserId", userId, true);
// HOTFIX: create person if not found to work around caching issue
person = await createPerson(environmentId, userId);
}
const updatedPerson = await updatePerson(person.id, inputValidation.data);
await updatePerson(person.id, inputValidation.data);
return responses.successResponse(updatedPerson, true);
return responses.successResponse({}, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);

View File

@@ -6,7 +6,7 @@ import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
@@ -147,7 +147,11 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
const userId = searchParams.userId;
if (userId) {
await getOrCreatePersonByUserId(userId, survey.environmentId);
// make sure the person exists or get's created
const person = await getPersonByUserId(survey.environmentId, userId);
if (!person) {
await createPerson(survey.environmentId, userId);
}
}
const isSurveyPinProtected = Boolean(!!survey && survey.pin);

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.2.1",
"version": "1.3.1",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -11,7 +11,6 @@
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "^3.451.0",
"@formbricks/api": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/ee": "workspace:*",

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"eslint": "^8.53.0",
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"eslint-config-prettier": "^9.0.0",
"eslint-config-turbo": "latest",

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.2.2",
"version": "1.2.3",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",
@@ -42,9 +42,9 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.8",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@types/jest": "^29.5.9",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"eslint-config-formbricks": "workspace:*",

View File

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

View File

@@ -5,6 +5,7 @@ import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -102,6 +103,7 @@ export const logoutPerson = async (): Promise<void> => {
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const syncParams = {
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,

View File

@@ -13,7 +13,7 @@ import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { createActionClass, getActionClassByEnvironmentIdAndName } from "../actionClass/service";
import { validateInputs } from "../utils/validate";
import { actionCache } from "./cache";
import { getPersonByUserId } from "../person/service";
import { createPerson, getPersonByUserId } from "../person/service";
export const getLatestActionByEnvironmentId = async (environmentId: string): Promise<TAction | null> => {
const action = await unstable_cache(
@@ -240,10 +240,11 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
actionType = "automatic";
}
const person = await getPersonByUserId(userId, environmentId);
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
throw new Error("Person not found");
// create person if it does not exist
person = await createPerson(environmentId, userId);
}
let actionClass = await getActionClassByEnvironmentIdAndName(environmentId, name);

View File

@@ -18,7 +18,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { getPersonByUserId } from "../person/service";
import { createPerson, getPersonByUserId } from "../person/service";
import { validateInputs } from "../utils/validate";
import { displayCache } from "./cache";
import { formatDisplaysDateFields } from "./util";
@@ -70,7 +70,7 @@ export const updateDisplay = async (
let person: TPerson | null = null;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
if (!person) {
throw new ResourceNotFoundError("Person", displayInput.userId);
}
@@ -157,16 +157,22 @@ export const updateDisplayLegacy = async (
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayCreateInput]);
const { environmentId, userId, surveyId } = displayInput;
try {
let person;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
if (userId) {
person = await getPersonByUserId(environmentId, userId);
if (!person) {
person = await createPerson(environmentId, userId);
}
}
const display = await prisma.display.create({
data: {
survey: {
connect: {
id: displayInput.surveyId,
id: surveyId,
},
},

View File

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

View File

@@ -12,11 +12,12 @@
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
},
"dependencies": {
"@formbricks/api": "*",
"@aws-sdk/client-s3": "3.451.0",
"@aws-sdk/s3-request-presigner": "3.451.0",
"@aws-sdk/s3-presigned-post": "^3.454.0",
"@aws-sdk/client-s3": "3.454.0",
"@aws-sdk/s3-request-presigner": "3.454.0",
"@t3-oss/env-nextjs": "^0.7.1",
"mime": "3.0.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
"@formbricks/types": "*",
"@paralleldrive/cuid2": "^2.2.2",

View File

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

View File

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

View File

@@ -188,6 +188,22 @@ export const createPerson = async (environmentId: string, userId: string): Promi
return transformedPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// If the person already exists, return it
if (error.code === "P2002") {
// HOTFIX to handle formbricks-js failing because of caching issue
// Handle the case where the person record already exists
const existingPerson = await prisma.person.findFirst({
where: {
environmentId,
userId,
},
select: selectPerson,
});
if (existingPerson) {
return transformPrismaPerson(existingPerson);
}
}
throw new DatabaseError(error.message);
}
@@ -291,10 +307,10 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
}
};
export const getPersonByUserId = async (userId: string, environmentId: string): Promise<TPerson | null> => {
const personPrisma = await unstable_cache(
export const getPersonByUserId = async (environmentId: string, userId: string): Promise<TPerson | null> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
validateInputs([environmentId, ZId], [userId, ZString]);
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
@@ -306,7 +322,7 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
});
if (personWithUserId) {
return personWithUserId;
return transformPrismaPerson(personWithUserId);
}
// Check if a person with the userId attribute exists
@@ -352,57 +368,9 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
userId,
});
return personWithUserIdAttribute;
return transformPrismaPerson(personWithUserIdAttribute);
},
[`getPersonByUserId-${userId}-${environmentId}`],
{
tags: [
personCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
personCache.tag.byUserId(userId), // fix for caching issue on vercel
personCache.tag.byEnvironmentId(environmentId), // fix for caching issue on vercel
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!personPrisma) {
return null;
}
return transformPrismaPerson(personPrisma);
};
export const getOrCreatePersonByUserId = async (userId: string, environmentId: string): Promise<TPerson> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
let person = await getPersonByUserId(userId, environmentId);
if (person) {
return person;
}
// create a new person
const personPrisma = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
userId,
},
select: selectPerson,
});
personCache.revalidate({
id: personPrisma.id,
environmentId,
userId,
});
return transformPrismaPerson(personPrisma);
},
[`getOrCreatePersonByUserId-${userId}-${environmentId}`],
[`getPersonByUserId-${environmentId}-${userId}`],
{
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,

View File

@@ -19,7 +19,7 @@ import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { deleteDisplayByResponseId } from "../display/service";
import { getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
import { createPerson, getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
import { formatResponseDateFields } from "../response/util";
import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
@@ -197,13 +197,16 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, surveyId, finished, data, meta, singleUseId } = responseInput;
try {
let person: TPerson | null = null;
if (responseInput.userId) {
person = await getPersonByUserId(responseInput.userId, responseInput.environmentId);
if (userId) {
person = await getPersonByUserId(environmentId, userId);
if (!person) {
throw new ResourceNotFoundError("Person", responseInput.userId);
// create person if it does not exist
person = await createPerson(environmentId, userId);
}
}
@@ -211,11 +214,11 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
data: {
survey: {
connect: {
id: responseInput.surveyId,
id: surveyId,
},
},
finished: responseInput.finished,
data: responseInput.data,
finished: finished,
data: data,
...(person?.id && {
person: {
connect: {
@@ -224,8 +227,8 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
},
personAttributes: person?.attributes,
}),
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
singleUseId: responseInput.singleUseId,
...(meta && ({ meta } as Prisma.JsonObject)),
singleUseId,
},
select: responseSelection,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +0,0 @@
import { IAuthenticateGeneric, ICredentialTestRequest, ICredentialType, INodeProperties } from "n8n-workflow";
export class FormbricksApi implements ICredentialType {
name = "formbricksApi";
displayName = "Formbricks API";
properties: INodeProperties[] = [
{
displayName: "Host",
name: "host",
description:
'The address of your Formbricks instance. For Formbricks Cloud this is "https://app.formbricks.com". If you are hosting Formbricks yourself, it\'s the address where you can reach your instance.',
type: "string",
default: "https://app.formbricks.com",
},
{
displayName: "API Key",
name: "apiKey",
description:
'Your Formbricks API-Key. You can create a new API-Key in the Product Settings. Please read our <a href="https://formbricks.com/docs/api/api-key-setup">API Key Docs</a> for more details.',
type: "string",
typeOptions: { password: true },
default: "",
},
];
authenticate: IAuthenticateGeneric = {
type: "generic",
properties: {
headers: {
"x-Api-Key": "={{$credentials.apiKey}}",
},
},
};
test: ICredentialTestRequest | undefined = {
request: {
baseURL: "={{$credentials.host}}/api/v1",
url: "=/me",
},
};
}

View File

@@ -1,16 +0,0 @@
const path = require("path");
const { task, src, dest } = require("gulp");
task("build:icons", copyIcons);
function copyIcons() {
const nodeSource = path.resolve("nodes", "**", "*.{png,svg}");
const nodeDestination = path.resolve("dist", "nodes");
src(nodeSource).pipe(dest(nodeDestination));
const credSource = path.resolve("credentials", "**", "*.{png,svg}");
const credDestination = path.resolve("dist", "credentials");
return src(credSource).pipe(dest(credDestination));
}

View File

@@ -1,18 +0,0 @@
{
"node": "n8n-nodes-base.Formbricks",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/formbricks"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.formbricks/"
}
]
}
}

View File

@@ -1,155 +0,0 @@
import { INodeType, INodeTypeDescription, IWebhookFunctions, IWebhookResponseData } from "n8n-workflow";
import { apiRequest, getSurveys } from "./GenericFunctions";
import { IHookFunctions } from "n8n-core";
export class Formbricks implements INodeType {
description: INodeTypeDescription = {
displayName: "Formbricks",
name: "formbricks",
icon: "file:formbricks.svg",
group: ["trigger"],
version: 1,
subtitle: '=Surveys: {{$parameter["surveyIds"]}}',
description: "Open Source Surveys & Experience Management Solution",
defaults: {
name: "Formbricks",
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ["main"],
credentials: [
{
name: "formbricksApi",
required: true,
},
],
webhooks: [
{
name: "default",
httpMethod: "POST",
responseMode: "onReceived",
path: "webhook",
},
],
properties: [
{
displayName: "Events",
name: "events",
type: "multiOptions",
options: [
{
name: "Response Created",
value: "responseCreated",
description:
"Triggers when a new response is created for a survey. Normally triggered after the first question was answered.",
},
{
name: "Response Updated",
value: "responseUpdated",
description: "Triggers when a response is updated within a survey",
},
{
name: "Response Finished",
value: "responseFinished",
description: "Triggers when a response is marked as finished",
},
],
default: [],
required: true,
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options
displayName: "Survey",
name: "surveyIds",
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-multi-options
description:
'Survey which should trigger workflow. Only trigger this node for a specific survey within the environment. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
type: "multiOptions",
typeOptions: {
loadOptionsMethod: "getSurveys",
},
options: [],
default: [],
required: true,
},
],
};
methods = {
loadOptions: {
getSurveys,
},
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData("node");
const webhookUrl = this.getNodeWebhookUrl("default");
const surveyIds = this.getNodeParameter("surveyIds") as Array<string>;
const endpoint = "/webhooks";
try {
const response = await apiRequest.call(this, "GET", endpoint, {});
for (const webhook of response.data) {
for (const surveyId of webhook.surveyIds) {
if (surveyIds.includes(surveyId) && webhook.url === webhookUrl) {
webhookData.webhookId = webhook.id;
return true;
}
}
}
} catch (error) {
return false;
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData("node");
const webhookUrl = this.getNodeWebhookUrl("default");
const surveyIds = this.getNodeParameter("surveyIds") as Array<string>;
const events = this.getNodeParameter("events");
const body = {
url: webhookUrl,
triggers: events,
surveyIds: surveyIds,
};
const endpoint = "/webhooks";
try {
const response = await apiRequest.call(this, "POST", endpoint, body);
webhookData.webhookId = response.data.id;
return true;
} catch (error) {
return false;
}
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData("node");
if (webhookData.webhookId !== undefined) {
const endpoint = `/webhooks/${webhookData.webhookId}`;
try {
await apiRequest.call(this, "DELETE", endpoint, {});
} catch (error) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registered anymore
delete webhookData.webhookId;
}
return false;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
// getting bodyData as string, so need to JSON parse it to convert to an object
return {
workflowData: [this.helpers.returnJsonArray(JSON.parse(bodyData as any))],
};
}
}

View File

@@ -1,69 +0,0 @@
import { IHookFunctions, ILoadOptionsFunctions } from "n8n-core";
import {
IDataObject,
IExecuteFunctions,
IHttpRequestMethods,
IHttpRequestOptions,
INodePropertyOptions,
JsonObject,
NodeApiError,
NodeOperationError,
} from "n8n-workflow";
/**
* Make an API request to Formbricks
*/
export async function apiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: object,
query: IDataObject = {},
option: IDataObject = {}
): Promise<any> {
const credentials = await this.getCredentials("formbricksApi");
let options: IHttpRequestOptions = {
baseURL: `${credentials.host}/api/v1`,
method,
body,
qs: query,
url: resource,
headers: {
"x-Api-Key": credentials.apiKey,
},
};
if (!Object.keys(query).length) {
delete options.qs;
}
options = Object.assign({}, options, option);
try {
return await this.helpers.httpRequestWithAuthentication.call(this, "formbricksApi", options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
}
/**
* Returns all the available surveys
*/
export async function getSurveys(this: ILoadOptionsFunctions): Promise<any> {
const endpoint = "/surveys";
const responseData = await apiRequest.call(this, "GET", endpoint, {});
if (!responseData.data) {
throw new NodeOperationError(this.getNode(), "No data got returned");
}
const returnData: INodePropertyOptions[] = [];
for (const data of responseData.data) {
returnData.push({
name: data.name,
value: data.id,
});
}
return returnData;
}

View File

@@ -1,75 +0,0 @@
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="500" height="500" fill="white"/>
<path d="M90 334H218V398.716C218 433.667 189.667 462 154.716 462H153.284C118.333 462 90 433.667 90 398.716V334Z" fill="url(#paint0_linear_3927_2011)"/>
<path d="M90 186H346.716C381.667 186 410 214.334 410 249.284V250.717C410 285.667 381.667 314 346.716 314H90V186Z" fill="url(#paint1_linear_3927_2011)"/>
<path d="M90 101.284C90 66.333 118.333 38 153.284 38H346.716C381.667 38 410 66.333 410 101.284V102.716C410 137.667 381.667 166 346.716 166H90V101.284Z" fill="url(#paint2_linear_3927_2011)"/>
<mask id="mask0_3927_2011" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="90" y="38" width="320" height="424">
<path d="M90 334H218V398.716C218 433.667 189.667 462 154.716 462H153.284C118.333 462 90 433.667 90 398.716V334Z" fill="url(#paint3_linear_3927_2011)"/>
<path d="M90 186H346.716C381.667 186 410 214.334 410 249.284V250.717C410 285.667 381.667 314 346.716 314H90V186Z" fill="url(#paint4_linear_3927_2011)"/>
<path d="M90 101.284C90 66.333 118.333 38 153.284 38H346.716C381.667 38 410 66.333 410 101.284V102.716C410 137.667 381.667 166 346.716 166H90V101.284Z" fill="url(#paint5_linear_3927_2011)"/>
</mask>
<g mask="url(#mask0_3927_2011)">
<g filter="url(#filter0_d_3927_2011)">
<mask id="mask1_3927_2011" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="90" y="38" width="320" height="424">
<path d="M90 334H218V398.716C218 433.667 189.667 462 154.716 462H153.284C118.333 462 90 433.667 90 398.716V334Z" fill="black" fill-opacity="0.1"/>
<path d="M90 101.284C90 66.333 118.333 38 153.284 38H346.716C381.667 38 410 66.333 410 101.284V102.716C410 137.667 381.667 166 346.716 166H90V101.284Z" fill="black" fill-opacity="0.1"/>
<path d="M90 186H346.716C381.667 186 410 214.334 410 249.284V250.717C410 285.667 381.667 314 346.716 314H90V186Z" fill="black" fill-opacity="0.1"/>
</mask>
<g mask="url(#mask1_3927_2011)">
<path d="M96.7149 -72.2506C146.856 -121.187 273.999 -72.2506 273.999 -72.2506H96.7149C84.3998 -60.2313 76.7298 -42.3079 76.7298 -16.3051C76.7298 115.567 219.58 163.522 219.58 255.433C219.58 345.407 82.6888 400.915 76.9176 523.174H273.999C273.999 523.174 76.7298 659.043 76.7298 531.166C76.7298 528.471 76.7933 525.807 76.9176 523.174H-10.0007L7.00526 -72.2506H96.7149Z" fill="black" fill-opacity="0.1"/>
</g>
</g>
<g filter="url(#filter1_f_3927_2011)">
<circle cx="50.0005" cy="406" r="120" fill="#00C4B8"/>
</g>
<g filter="url(#filter2_f_3927_2011)">
<circle cx="50.0005" cy="102" r="120" fill="#00C4B8"/>
</g>
</g>
<defs>
<filter id="filter0_d_3927_2011" x="83.6716" y="0.0298538" width="259.94" height="499.94" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="31.6418"/>
<feGaussianBlur stdDeviation="18.9851"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3927_2011"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3927_2011" result="shape"/>
</filter>
<filter id="filter1_f_3927_2011" x="-133.283" y="222.717" width="366.567" height="366.567" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="31.6418" result="effect1_foregroundBlur_3927_2011"/>
</filter>
<filter id="filter2_f_3927_2011" x="-133.283" y="-81.2831" width="366.567" height="366.567" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="31.6418" result="effect1_foregroundBlur_3927_2011"/>
</filter>
<linearGradient id="paint0_linear_3927_2011" x1="218.557" y1="395.681" x2="89.989" y2="396.2" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint1_linear_3927_2011" x1="411.391" y1="247.682" x2="90" y2="250.928" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint2_linear_3927_2011" x1="411.391" y1="99.6812" x2="90" y2="102.928" gradientUnits="userSpaceOnUse">
<stop stop-color="#00E6CA"/>
<stop offset="1" stop-color="#00C4B8"/>
</linearGradient>
<linearGradient id="paint3_linear_3927_2011" x1="218.557" y1="395.681" x2="89.989" y2="396.2" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint4_linear_3927_2011" x1="411.391" y1="247.682" x2="90" y2="250.928" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
<linearGradient id="paint5_linear_3927_2011" x1="411.391" y1="99.6812" x2="90" y2="102.928" gradientUnits="userSpaceOnUse">
<stop stop-color="#00FFE1"/>
<stop offset="1" stop-color="#01E0C6"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,55 +0,0 @@
{
"name": "@formbricks/n8n-nodes-formbricks",
"version": "0.2.0",
"description": "A n8n node to connect Formbricks and send survey data to hundreds of other apps.",
"keywords": [
"n8n-community-node-package",
"n8n",
"trigger",
"Formbricks",
"n8n-node",
"surveys",
"experience management"
],
"license": "MIT",
"homepage": "https://formbricks.com",
"author": {
"name": "PratikAwaik",
"email": "pratikawaik125@gmail.com"
},
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"clean": "rimraf .turbo node_modules dist",
"dev": "tsc --watch",
"format": "prettier nodes credentials --write",
"lint": "eslint nodes credentials package.json",
"lintfix": "eslint nodes credentials package.json --fix",
"prepublishOnly": "pnpm run build && pnpm run lint -c .eslintrc.prepublish.js nodes credentials package.json"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/FormbricksApi.credentials.js"
],
"nodes": [
"dist/nodes/Formbricks/Formbricks.node.js"
]
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/request-promise-native": "~1.0.21",
"@typescript-eslint/parser": "~6.11",
"eslint-plugin-n8n-nodes-base": "^1.16.1",
"gulp": "^4.0.2",
"n8n-core": "legacy",
"n8n-workflow": "legacy"
}
}

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"strict": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2019",
"lib": ["es2019", "es2020", "es2022.error"],
"removeComments": true,
"useUnknownInCatchVariables": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"incremental": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "nodes/**/*.json", "package.json"]
}

View File

@@ -1,83 +0,0 @@
{
"linterOptions": {
"exclude": ["node_modules/**/*"]
},
"defaultSeverity": "error",
"jsRules": {},
"rules": {
"array-type": [true, "array-simple"],
"arrow-return-shorthand": true,
"ban": [
true,
{
"name": "Array",
"message": "tsstyle#array-constructor"
}
],
"ban-types": [
true,
["Object", "Use {} instead."],
["String", "Use 'string' instead."],
["Number", "Use 'number' instead."],
["Boolean", "Use 'boolean' instead."]
],
"class-name": true,
"curly": [true, "ignore-same-line"],
"forin": true,
"jsdoc-format": true,
"label-position": true,
"indent": [true, "tabs", 2],
"member-access": [true, "no-public"],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-conditional-assignment": true,
"no-construct": true,
"no-debugger": true,
"no-default-export": true,
"no-duplicate-variable": true,
"no-inferrable-types": true,
"ordered-imports": [
true,
{
"import-sources-order": "any",
"named-imports-order": "case-insensitive"
}
],
"no-namespace": [true, "allow-declarations"],
"no-reference": true,
"no-string-throw": true,
"no-unused-expression": true,
"no-var-keyword": true,
"object-literal-shorthand": true,
"only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
"prefer-const": true,
"radix": true,
"semicolon": [true, "always", "ignore-bound-class-methods"],
"switch-default": true,
"trailing-comma": [
true,
{
"multiline": {
"objects": "always",
"arrays": "always",
"functions": "always",
"typeLiterals": "ignore"
},
"esSpecCompliant": true
}
],
"triple-equals": [true, "allow-null-check"],
"use-isnan": true,
"quotes": ["error", "single"],
"variable-name": [
true,
"check-format",
"ban-keywords",
"allow-leading-underscore",
"allow-trailing-underscore"
]
},
"rulesDirectory": []
}

View File

@@ -25,7 +25,7 @@
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "^2.6.0",
"@preact/preset-vite": "^2.7.0",
"autoprefixer": "^10.4.16",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.31",

View File

@@ -104,20 +104,20 @@ export default function Modal({
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-hidden rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
)}>
{!isCenter && (
<div class="absolute right-0 top-0 block pr-[1.4rem] pt-2">
<div class="absolute right-0 top-0 block pr-2 pt-2">
<button
type="button"
onClick={onClose}
class="text-close-button hover:text-close-button-focus focus:ring-close-button-focus relative rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2">
<span class="sr-only">Close</span>
class="text-close-button hover:text-close-button-focus focus:ring-close-button-focus relative h-5 w-5 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2">
<span class="sr-only">Close survey</span>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke-width="2"
stroke="currentColor"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4L20 20M4 20L20 4" />
</svg>
</button>
</div>

View File

@@ -7,9 +7,9 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "20.9.0",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"typescript": "^5.2.2"
"@types/node": "20.9.3",
"@types/react": "18.2.38",
"@types/react-dom": "18.2.16",
"typescript": "^5.3.2"
}
}

View File

@@ -31,7 +31,7 @@ export type TDisplayLegacyCreateInput = z.infer<typeof ZDisplayLegacyCreateInput
export const ZDisplayUpdateInput = z.object({
environmentId: z.string().cuid(),
userId: z.string().cuid().optional(),
userId: z.string().optional(),
responseId: z.string().cuid().optional(),
});

View File

@@ -10,6 +10,10 @@ export default async function EnvironmentNotice({ environmentId }: EnvironmentNo
const headersList = headers();
const currentUrl = headersList.get("referer") || headersList.get("x-invoke-path") || "";
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const environments = await getEnvironments(environment.productId);
const otherEnvironmentId = environments.find((e) => e.id !== environment.id)?.id || "";

View File

@@ -19,13 +19,13 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/lib": "workspace:*",
"@heroicons/react": "^2.0.18",
"@lexical/code": "^0.12.2",
"@lexical/link": "^0.12.2",
"@lexical/list": "^0.12.2",
"@lexical/markdown": "^0.12.2",
"@lexical/react": "^0.12.2",
"@lexical/rich-text": "^0.12.2",
"@lexical/table": "^0.12.2",
"@lexical/code": "^0.12.4",
"@lexical/link": "^0.12.4",
"@lexical/list": "^0.12.4",
"@lexical/markdown": "^0.12.4",
"@lexical/react": "^0.12.4",
"@lexical/rich-text": "^0.12.4",
"@lexical/table": "^0.12.4",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -40,7 +40,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"lexical": "^0.12.2",
"lexical": "^0.12.4",
"lucide-react": "^0.292.0",
"react-colorful": "^5.6.1",
"react-confetti": "^6.1.0",

3642
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff