Compare commits

..

29 Commits

Author SHA1 Message Date
Matti Nannt
aea4b8e1b8 Merge branch 'main' into feature/1.5.1 2024-02-04 12:44:48 +01:00
Matthias Nannt
8f9d62272c dependency changes 2024-02-04 12:44:35 +01:00
Jonas Höbenreich
9756aed94f feat: Add support for Cloudfront country header (#2003) 2024-02-04 11:31:34 +00:00
Matthias Nannt
c9954562d5 add missing use client 2024-02-04 12:28:32 +01:00
Matthias Nannt
102590db6a Merge branch 'main' of github.com:formbricks/formbricks into feature/1.5.1 2024-02-04 12:00:55 +01:00
Matthias Nannt
e1e1c1f497 package upgrades 2024-02-04 12:00:49 +01:00
Matti Nannt
97562118a1 fix: create survey API expects creator (#2007) 2024-02-04 09:03:38 +00:00
Sudhanshu Pandey
fd217308e1 fix: ECS deployment GitHub Action (#2005) 2024-02-02 19:26:00 +00:00
Matthias Nannt
0f752e29c8 fix lint issues 2024-02-02 13:35:26 +01:00
Matthias Nannt
0920bf4e35 fix build issues 2024-02-02 13:05:06 +01:00
Matthias Nannt
f7f1813d63 chore: prepare 1.5.1 release 2024-02-02 11:44:29 +01:00
Matti Nannt
6d4098b8b8 chore: remove old CLA form from docs & github templates (#1995) 2024-02-02 09:09:58 +00:00
Sudhanshu Pandey
2f7b70516c fix: ecs github action (#1992) 2024-02-02 08:58:14 +00:00
Shubham Palriwala
9761483530 feat: auto subscribing to teams survey responses email (#1990)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-02-01 17:20:54 +00:00
Matti Nannt
6cea8a2246 fix: formbricks-com build errors (#1991) 2024-02-01 10:08:25 +00:00
Dhruwang Jariwala
b7250a284a fix: default autocomplete value (#1986)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-02-01 09:30:17 +00:00
Dhruwang Jariwala
1402f4a48b chore: Tweaked survey list (#1978)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-02-01 07:51:21 +00:00
Shubham Palriwala
70fe0fb7a7 feat: enable weekly summary & support for callbacks on login (#1885)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-02-01 06:16:21 +00:00
Sudhanshu Pandey
5471324cfe fix: ecs-deployment.yml (#1989) 2024-01-31 20:45:40 +00:00
Sudhanshu Pandey
0a78848612 fix: ECS deployment Github Action (#1988) 2024-01-31 19:39:40 +00:00
Dhruwang Jariwala
f553bce7f1 fix: links and survey overlapping (#1985)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-01-31 15:53:51 +00:00
Dhruwang Jariwala
43566a54b6 fix: Phone validation (#1987) 2024-01-31 14:21:16 +00:00
Matti Nannt
51a811ac8e fix: app page redirect throws error (#1984) 2024-01-31 10:59:17 +00:00
Sudhanshu Pandey
74fba30d5f feat(github action): Add's a new GitHub Action to deploy webapp to ECS cluster (#1982)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-01-31 10:57:25 +00:00
Shubham Palriwala
22ea797b15 feat: (docs) quickstart for link surveys (#1981)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-01-30 20:52:31 +00:00
Johannes
0ce10e8824 docs: Add newsletter survey to best practices (#1980)
Co-authored-by: Olasunkanmi Balogun <olasunkanmiibalogun@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-01-30 15:41:27 +00:00
Matti Nannt
ca0d63a0fc chore: remove getting started email (#1977) 2024-01-30 11:52:14 +00:00
Matti Nannt
2a9b34104d feat: add support for customer-io formbricks users sync (#1976) 2024-01-30 11:08:40 +00:00
Shubham Palriwala
16cbc3365b feat: custom placeholder label for other option in single & multi select (#1971)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-01-30 09:21:01 +00:00
108 changed files with 3643 additions and 2205 deletions

View File

@@ -12,8 +12,8 @@
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": ["dbaeumer.vscode-eslint"]
}
"extensions": ["dbaeumer.vscode-eslint"],
},
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -25,5 +25,5 @@
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
"remoteUser": "node",
}

View File

@@ -42,7 +42,6 @@ body:
- First time: Please read our [introductory blog post](https://formbricks.com/blog/join-the-formtribe)
- All UI components are in the package `formbricks/ui`
- Run `pnpm go` to find a demo app to test in-app surveys at `localhost:3002`
- Everything is type-safe
- We use **chatGPT** to help refactor code. Use our [Formbricks ✨ megaprompt ✨](https://github.com/formbricks/formbricks/blob/main/megaprompt.md) to create the right
context before you write your prompt.
- Everything is type-safe.
- We use **chatGPT** to help refactor code.
- Anything unclear? [Ask in Discord](https://formbricks.com/discord)

View File

@@ -32,7 +32,6 @@ Fixes # (issue)
- [ ] Removed all `console.logs`
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
- [ ] My changes don't cause any responsiveness issues
- [ ] First PR at Formbricks? [Please sign the CLA!](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx) Without it we wont be able to merge it 🙏
### Appreciated

View File

@@ -1,4 +1,4 @@
name: Build
name: Build formbricks-com
on:
workflow_call:
jobs:
@@ -11,10 +11,10 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Node.js 18.x
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v2

View File

@@ -1,4 +1,4 @@
name: Build
name: Build web
on:
workflow_call:
jobs:

137
.github/workflows/ecs-deployment.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: ECS
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches:
- main
workflow_dispatch: # Add manual trigger support
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: formbricks/formbricks-experimental
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Generate Random NEXTAUTH_SECRET
run: |
SECRET=$(openssl rand -hex 32)
echo "NEXTAUTH_SECRET=$SECRET" >> $GITHUB_ENV
- name: Generate Random ENCRYPTION_KEY
run: |
SECRET=$(openssl rand -hex 32)
echo "ENCRYPTION_KEY=$SECRET" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Depot CLI
uses: depot/setup-action@v1
# https://github.com/sigstore/cosign-installer
- name: Install cosign
uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1
with:
cosign-release: "v2.1.1"
# https://github.com/docker/login-action
- name: Log into registry
uses: docker/login-action@v3 # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: depot/build-push-action@v1
env:
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: .
file: ./apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
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 }}
NEXT_PUBLIC_SENTRY_DSN=${{ env.NEXT_PUBLIC_SENTRY_DSN }}
- name: Sign the images with GitHub OIDC Token
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
images=""
for tag in ${TAGS}; do
images+="${tag}@${DIGEST} "
done
cosign sign --yes ${images}
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Download task definition
run: |
aws ecs describe-task-definition --task-definition prod-webapp-ecs-service --query taskDefinition > task-definition.json
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: prod-webapp-container
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: prod-webapp-ecs-service
cluster: prod-core-infra-ecs-cluster
wait-for-service-stability: true

View File

@@ -126,8 +126,6 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
(In the future we may develop additional features that aren't in the free Open-Source version).
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -0,0 +1,113 @@
import DemoPreview from "@/components/dummyUI/DemoPreview";
import Image from "next/image";
import NewsletterSurveyType from './choose-survey-type.webp';
import NewsletterSurveyEmbedCode from './embed-survey-code-in-your-email.webp';
import NewsletterSurveyEmbedPrompt from './embed-survey-prompt.webp';
import NewsletterSurveyEditor from './improve-newsletter-content-editor-formbricks.webp';
import NewsletterSurvey from './improve-newsletter-content-survey-location.webp';
export const metadata = {
title: "Measure email content quality with Formbricks",
description:
"Measuring the content quality of both transactional and marketing email is a key element for improving customer communication.",
};
#### Best Practices
# Improve Email Content
Email remains the predominant way to communicate with your customers. Measure the effectiveness to improve your offering.
## Purpose
Measuring the content quality of both transactional and marketing email is a key element for improving customer communication.
## Preview
<DemoPreview template="Improve Newsletter Content" />
## Formbricks Approach
- Embed the survey into your email so its part of the newsletter.
- Use link prefilling to store the answer users clicked on in the email.
- Dynamic user identification to append reader's email for personalized profiles and follow ups.
## Installation
To embed the newsletter survey into your email, follow these steps:
1. Create new 'Improve Newsletter Content' survey at [app.formbricks.com](https://app.formbricks.com/)
2. Select how you where you want to display the survey.
3. Copy the embed code anywhere you want in your newsletter.
### 1. Create new 'Improve Newsletter Content' Survey
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Then, create a new survey and look for the "Improve Newsletter Content" template:
<Image
src={NewsletterSurvey}
alt="Create Improve Newsletter Content by template"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
### 2. Customize Survey questions
Customize survey questions, emojis or stars however you like:
<Image
src={NewsletterSurveyEditor}
alt="Edit Improve Newsletter Content template"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
### 3. Configure Survey Settings
When you are done customizing your survey questions, navigate to the Settings tab and choose the type of survey you want. You need to choose Link Survey:
<Image
src={NewsletterSurveyType}
alt="Choose survey type"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
### 4. Choose how you want to embed your survey
After publishing your survey, a modal that prompts you to embed your survey will pop up.
<Image
src={NewsletterSurveyEmbedPrompt}
alt="Embed newsletter survey"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
Select the Embed Survey card and you will be directed to another modal, where the first embed option displayed will be to embed the survey in an email.
### 5. Copy code to embed the survey in your newsletter
Click the button with the “View Embed Code” text at the top right corner of the modal and simply paste the HTML code for your survey anywhere you want it in your newsletter. You can see the preview in the below image:
<Image
src={NewsletterSurveyEmbedCode}
alt="Embed survey code"
quality="100"
className="rounded-lg max-w-full sm:max-w-3xl"
/>
And you're done! Send a test email to yourself and try it out 🤓
## Learn about data prefilling
<Note>
## How does data prefilling work?
Learn about how link prefilling and user identification maximize your insights in [this detailed guide](/blog/how-smart-writers-use-formbricks-open-source-tool-to-measure-the-quality-of-their-newsletter-content).
</Note>
### &nbsp;
# Thats it! 🎉

View File

@@ -42,6 +42,4 @@ If you are at all unsure, just raise it as an enhancement issue first and tell u
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
Please note that we can only get your contribution merged when we have a CLA signed by you.
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)
Once you open a PR, you will get a message from the CLA bot to fill out the form. Please note that we can only get your contribution merged when we have a CLA signed by you.

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,88 @@
import Image from "next/image";
import HomePage from "./home-page.webp";
import SurveyQuestions from "./survey-questions.webp";
import SurveySettings from "./survey-settings.webp";
import SurveyResponseOptions from "./survey-response-options.webp";
import SurveyPublished from "./survey-published.webp";
export const metadata = {
title: "Formbricks Quickstart Guide: Link Surveys Made Easier & Faster",
description:
"Formbricks is the easiest way to create and manage link surveys. This quickstart guide will show you how to create your first link survey in under 5 minutes.",
};
#### Getting Started
# Quickstart
Link Surveys make it easy for your users to give you feedback. They are a great way to get feedback from your users, without interrupting their workflow. This quickstart guide will show you how to create your first link survey in under 5 minutes.
## Create a free Formbricks Cloud account
While you can [self-host](/docs/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and click through the onboarding, until youre here:
<Image
src={HomePage}
alt="Choose a link survey template"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl "
/>
Choose one of the pre-created templates to get started. Well choose the **Product Market Fit** template for this quickstart guide.
## Create your first survey
On clicking the template, youll be forwarded to the survey editor. Here you can edit the survey questions and settings. For the sake of simplicity, we'll keep the questions as they are and move to the survey settings.
<Image
src={SurveyQuestions}
alt="Survey Editor opens up in the Formbricks App"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
Click on the **Settings** tab to edit the survey settings.
## Configure your survey settings
Formbricks packs a lot of useful functionality out of the box. You can:
- Close the survey on a specidic date
- After a number of response
- Redirect users to a URL after they completed the survey
- Protect survey with a Pin
- ... and much more!
<Image
src={SurveyResponseOptions}
alt="Survey response configuration for link survey"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Style your survey
Style your survey to your need. You can keep it simplistic or use animated backgrounds. You can change the main color and soon you'll be able to fully control the appearance of the survey.
<Image
src={SurveySettings}
alt="UI & View configuration for link survey"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Publish your survey
Once youre happy with the survey settings, hit **Publish** and youll be forwarded to the Summary Page. This is where youll find the responses to this survey.
<Image
src={SurveyPublished}
alt="Survey published successfully and received link to share with users"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
## Share your survey
Congratulations! Your survey is now published and ready to be shared with your users. You can share the survey link via email, SMS, or any other channel.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { Button } from "@formbricks/ui/Button";
import { FooterLogo } from "../../components/shared/Logo";
import { FooterLogo } from "../shared/Logo";
export default function HeaderLight() {
const plausible = usePlausible();

View File

@@ -1,5 +1,5 @@
import Footer from "../../components/shared/Footer";
import MetaInformation from "../../components/shared/MetaInformation";
import Footer from "../shared/Footer";
import MetaInformation from "../shared/MetaInformation";
import HeaderLight from "./HeaderLight";
interface LayoutProps {

View File

@@ -202,6 +202,7 @@ export const navigation: Array<NavGroup> = [
{
title: "Link Surveys",
links: [
{ title: "Quickstart", href: "/docs/link-surveys/quickstart" },
{ title: "Data Prefilling", href: "/docs/link-surveys/data-prefilling" },
{ title: "Identify Users", href: "/docs/link-surveys/user-identification" },
{ title: "Single Use Links", href: "/docs/link-surveys/single-use-links" },
@@ -218,6 +219,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Feature Chaser", href: "/docs/best-practices/feature-chaser" },
{ title: "Feedback Box", href: "/docs/best-practices/feedback-box" },
{ title: "Docs Feedback", href: "/docs/best-practices/docs-feedback" },
{ title: "Improve Email Content", href: "/docs/best-practices/improve-email-content" },
],
},
{

View File

@@ -53,12 +53,11 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
{templates
.filter((template) => selectedFilter === ALL_CATEGORY_NAME || template.category === selectedFilter)
.map((template: TTemplate) => (
<button
type="button"
<div
key={template.name}
onClick={() => {
onTemplateClick(template); // Pass the 'template' object instead of 'activeTemplate'
}}
key={template.name}
className={cn(
activeTemplate?.name === template.name && "ring-brand ring-2",
"duration-120 group relative rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105 dark:bg-slate-700"
@@ -71,7 +70,7 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
{template.name}
</h3>
<p className="text-left text-xs text-slate-600 dark:text-slate-400">{template.description}</p>
</button>
</div>
))}
</div>
</main>

View File

@@ -14,6 +14,7 @@ import {
DashboardIcon,
DogChaserIcon,
DoorIcon,
EmailIcon,
FeedbackIcon,
GaugeSpeedFastIcon,
HeartCommentIcon,
@@ -1224,6 +1225,61 @@ export const templates: TTemplate[] = [
},
},
},
{
name: "Improve Newsletter Content",
icon: EmailIcon,
category: "Growth",
description: "Find out how your subscribers like your newsletter content.",
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
preset: {
name: "Improve Newsletter Content",
questions: [
{
id: createId(),
type: TSurveyQuestionType.Rating,
logic: [
{ value: "5", condition: "equals", destination: "l2q1chqssong8n0xwaagyl8g" },
{ value: "5", condition: "lessThan", destination: "k3s6gm5ivkc5crpycdbpzkpa" },
],
range: 5,
scale: "smiley",
headline: "How would you rate this weeks newsletter?",
required: true,
subheader: "",
lowerLabel: "Meh",
upperLabel: "Great",
},
{
id: "k3s6gm5ivkc5crpycdbpzkpa",
type: TSurveyQuestionType.OpenText,
logic: [
{ condition: "submitted", destination: "end" },
{ condition: "skipped", destination: "end" },
],
headline: "What would have made this weeks newsletter more helpful?",
required: false,
placeholder: "Type your answer here...",
inputType: "text",
},
{
id: "l2q1chqssong8n0xwaagyl8g",
html: '<p class="fb-editor-paragraph" dir="ltr"><span>Who thinks like you? You\'d do us a huge favor if you\'d share this weeks episode with your brain friend!</span></p>',
type: TSurveyQuestionType.CTA,
headline: "Thanks! ❤️ Spread the love with ONE friend.",
required: false,
buttonUrl: "https://formbricks.com",
buttonLabel: "Happy to help!",
buttonExternal: true,
dismissButtonLabel: "Find your own friends",
},
],
welcomeCard: welcomeCardDefault,
thankYouCard: thankYouCardDefault,
hiddenFields: {
enabled: false,
},
},
},
];
export const findTemplateByName = (name: string): TTemplate | undefined => {

View File

@@ -79,6 +79,15 @@ export default function BestPracticeNavigation() {
description: "Give users the chance to share feedback in a single click.",
category: "Boost Retention",
},
{
name: "Improve Newsletter Content",
href: "/improve-newsletter-content",
status: true,
icon: FeedbackIcon,
description: "Improve your newsletter content by showing this survey to your readers.",
category: "Boost Retention",
},
];
return (

View File

@@ -155,11 +155,6 @@ const nextConfig = {
destination: "/docs/self-hosting/migration-guide",
permanent: true,
},
{
source: "/cla",
destination: "https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx",
permanent: true,
},
{
source: "/docs/contributing/gitpod",
destination: "/docs/contributing/setup#gitpod",

View File

@@ -1,10 +1,11 @@
import Image from "next/image";
import LayoutMdx from "@/components/shared/LayoutMdx";
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
import GitpodImage from "./setup-formbricks-via-gitpod.png";
import PackagesFolderImage from "./formbricks-packages-folder.png";
import AuthorBox from "@/components/shared/AuthorBox";
import LayoutMdx from "@/components/shared/LayoutMdx";
import Image from "next/image";
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
import PackagesFolderImage from "./formbricks-packages-folder.png";
import GitpodImage from "./setup-formbricks-via-gitpod.png";
export const meta = {
title: "Join the FormTribe 🔥",
@@ -16,7 +17,7 @@ export const meta = {
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"}/>
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"} />
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
@@ -54,10 +55,10 @@ To get up and running we have 2 options: Gitpod and local.
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/formbricks/formbricks)
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
[Read more in our docs](https://formbricks.com/docs/contributing/setup#gitpod-guide)
#### Run on a local machine
If you choose to get setup locally, we also have a well documented guide to hold you through the process, you can find it [here](https://formbricks.com/docs/contributing/setup)

View File

@@ -1,12 +1,12 @@
import Layout from "@/components/demo/LayoutLight";
import DemoView from "@/components/dummyUI/DemoView";
import LayoutWaitlist from "@/pages/demo/LayoutLight";
export default function DemoPage() {
return (
<LayoutWaitlist
<Layout
title="Formbricks Demo"
description="Play around with our pre-defined 30+ templates and them to kick-start your survey & experience management.">
<DemoView />
</LayoutWaitlist>
</Layout>
);
}

View File

@@ -23,6 +23,8 @@ ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ARG ENCRYPTION_KEY
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
ARG NEXT_PUBLIC_SENTRY_DSN
# Set the working directory
WORKDIR /app
@@ -76,4 +78,4 @@ CMD supercronic -quiet /app/docker/cronjobs & \
else \
echo "ERROR: Please set a value for NEXTAUTH_SECRET in your docker compose variables!" >&2; \
exit 1; \
fi
fi

View File

@@ -1,20 +1,16 @@
"use server";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
export const createShortUrlAction = async (url: string) => {
@@ -44,193 +40,28 @@ export async function createTeamAction(teamName: string): Promise<Team> {
accepted: true,
});
await createProduct(newTeam.id, {
const product = await createProduct(newTeam.id, {
name: "My Product",
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
return newTeam;
}
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId);
return duplicatedSurvey;
}
export async function copyToOtherEnvironmentAction(
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
session.user.id,
environmentId
);
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
session.user.id,
targetEnvironmentId
);
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
},
});
surveyCache.revalidate({
id: newSurvey.id,
environmentId: targetEnvironmentId,
});
return newSurvey;
}
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export const createProductAction = async (environmentId: string, productName: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -244,6 +75,20 @@ export const createProductAction = async (environmentId: string, productName: st
const product = await createProduct(team.id, {
name: productName,
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
// get production environment
const productionEnvironment = product.environments.find((environment) => environment.type === "production");

View File

@@ -504,7 +504,7 @@ export default function Navigation({
)}
<DropdownMenuItem
onClick={async () => {
await signOut();
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">

View File

@@ -98,8 +98,11 @@ export const leaveTeamAction = async (teamId: string) => {
};
export const createInviteTokenAction = async (inviteId: string) => {
const { email } = await getInvite(inviteId);
const inviteToken = createInviteToken(inviteId, email, {
const invite = await getInvite(inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
const inviteToken = createInviteToken(inviteId, invite.email, {
expiresIn: "7d",
});

View File

@@ -2,8 +2,8 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -13,9 +13,7 @@ export async function updateNotificationSettingsAction(notificationSettings: TUs
throw new AuthorizationError("Not authenticated");
}
// update user with notification settings
await prisma.user.update({
where: { id: session.user.id },
data: { notificationSettings },
await updateUser(session.user.id, {
notificationSettings,
});
}

View File

@@ -1,27 +1,49 @@
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { Membership, User } from "../types";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: User;
user: TUser;
environmentId: string;
autoDisableNotificationType: string;
autoDisableNotificationElementId: string;
}
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
export default function EditAlerts({
memberships,
user,
environmentId,
autoDisableNotificationType,
autoDisableNotificationElementId,
}: EditAlertsProps) {
return (
<>
{memberships.map((membership) => (
<>
<div className="mb-5 flex items-center space-x-3 font-semibold">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
<div className="col-span-3 flex items-center space-x-3">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
</div>
<p className="font-semibold text-slate-800">{membership.team.name}</p>
</div>
<div className="col-span-3 flex items-center justify-end pr-2">
<p className="pr-4 text-sm text-slate-600">Auto-subscribe to new surveys</p>
<NotificationSwitch
surveyOrProductOrTeamId={membership.team.id}
notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedTeamIds"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
@@ -57,9 +79,11 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProductId={survey.id}
notificationSettings={user.notificationSettings}
surveyOrProductOrTeamId={survey.id}
notificationSettings={user.notificationSettings!}
notificationType={"alert"}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
</div>

View File

@@ -1,12 +1,14 @@
import { UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { Membership, User } from "../types";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps {
memberships: Membership[];
user: User;
user: TUser;
environmentId: string;
}
@@ -34,8 +36,8 @@ export default function EditWeeklySummary({ memberships, user, environmentId }:
<div className="col-span-2">{product?.name}</div>
<div className="col-span-1 flex items-center justify-center">
<NotificationSwitch
surveyOrProductId={product.id}
notificationSettings={user.notificationSettings}
surveyOrProductOrTeamId={product.id}
notificationSettings={user.notificationSettings!}
notificationType={"weeklySummary"}
/>
</div>

View File

@@ -1,7 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -10,35 +9,90 @@ import { Switch } from "@formbricks/ui/Switch";
import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProductId: string;
surveyOrProductOrTeamId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary";
notificationType: "alert" | "weeklySummary" | "unsubscribedTeamIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
}
export function NotificationSwitch({
surveyOrProductId,
surveyOrProductOrTeamId,
notificationSettings,
notificationType,
autoDisableNotificationType,
autoDisableNotificationElementId,
}: NotificationSwitchProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const isChecked =
notificationType === "unsubscribedTeamIds"
? !notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)
: notificationSettings[notificationType][surveyOrProductOrTeamId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedTeamIds") {
const unsubscribedTeamIds = updatedNotificationSettings.unsubscribedTeamIds ?? [];
if (unsubscribedTeamIds.includes(surveyOrProductOrTeamId)) {
updatedNotificationSettings.unsubscribedTeamIds = unsubscribedTeamIds.filter(
(id) => id !== surveyOrProductOrTeamId
);
} else {
updatedNotificationSettings.unsubscribedTeamIds = [...unsubscribedTeamIds, surveyOrProductOrTeamId];
}
} else {
updatedNotificationSettings[notificationType][surveyOrProductOrTeamId] =
!updatedNotificationSettings[notificationType][surveyOrProductOrTeamId];
}
await updateNotificationSettingsAction(updatedNotificationSettings);
setIsLoading(false);
};
useEffect(() => {
if (
autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrProductOrTeamId &&
isChecked
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType][surveyOrProductOrTeamId] === true) {
handleSwitchChange();
toast.success("You will not receive any more emails for responses on this survey!", {
id: "notification-switch",
});
}
break;
case "unsubscribedTeamIds":
if (!notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)) {
handleSwitchChange();
toast.success("You will not be auto-subscribed to this team's surveys anymore!", {
id: "notification-switch",
});
}
break;
default:
break;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Switch
id="notification-switch"
aria-label="toggle notification settings"
checked={notificationSettings[notificationType][surveyOrProductId]}
aria-label={`toggle notification settings for ${notificationType}`}
checked={isChecked}
disabled={isLoading}
onCheckedChange={async () => {
setIsLoading(true);
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[notificationType][surveyOrProductId] =
!updatedNotificationSettings[notificationType][surveyOrProductId];
await updateNotificationSettingsAction(notificationSettings);
setIsLoading(false);
toast.success(`Notification settings updated`, { id: "notification-switch" });
router.refresh();
await handleSwitchChange();
toast.success("Notification settings updated", { id: "notification-switch" });
}}
/>
);

View File

@@ -3,42 +3,24 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { authOptions } from "@formbricks/lib/authOptions";
import { getUser } from "@formbricks/lib/user/service";
import { TUserNotificationSettings } from "@formbricks/types/user";
import SettingsTitle from "../components/SettingsTitle";
import EditAlerts from "./components/EditAlerts";
import EditWeeklySummary from "./components/EditWeeklySummary";
import IntegrationsTip from "./components/IntegrationsTip";
import type { Membership, User } from "./types";
import type { Membership } from "./types";
async function getUser(userId: string | undefined): Promise<User> {
if (!userId) {
throw new Error("Unauthorized");
}
const userData = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
notificationSettings: true,
},
});
if (!userData) {
throw new Error("Unauthorized");
}
const user = JSON.parse(JSON.stringify(userData)); // hack to remove the JsonValue type from the notificationSettings
return user;
}
function cleanNotificationSettings(
function setCompleteNotificationSettings(
notificationSettings: TUserNotificationSettings,
memberships: Membership[]
) {
const newNotificationSettings = { alert: {}, weeklySummary: {} };
): TUserNotificationSettings {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedTeamIds: notificationSettings.unsubscribedTeamIds || [],
};
for (const membership of memberships) {
for (const product of membership.team.products) {
// set default values for weekly summary
@@ -95,13 +77,22 @@ async function getMemberships(userId: string): Promise<Membership[]> {
return memberships;
}
export default async function ProfileSettingsPage({ params }) {
export default async function ProfileSettingsPage({ params, searchParams }) {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
if (!user) {
throw new Error("User not found");
}
if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
}
return (
<div>
@@ -109,11 +100,16 @@ export default async function ProfileSettingsPage({ params }) {
<SettingsCard
title="Email alerts (Surveys)"
description="Set up an alert to get an email on new responses.">
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
<EditAlerts
memberships={memberships}
user={user}
environmentId={params.environmentId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
beta
title="Weekly summary (Products)"
description="Stay up-to-date with a Weekly every Monday.">
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />

View File

@@ -55,7 +55,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
try {
setDeleting(true);
await deleteUserAction();
await signOut();
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
} catch (error) {
toast.error("Something went wrong");

View File

@@ -27,10 +27,7 @@ export default function SurveyStatusDropdown({
<>
{survey.status === "draft" ? (
<div className="flex items-center">
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
<p className="text-sm italic text-slate-600">Draft</p>
</div>
) : (
<Select

View File

@@ -217,29 +217,44 @@ export default function MultipleChoiceMultiForm({
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
onBlur={() => {
const duplicateLabel = findDuplicateLabel();
if (duplicateLabel) {
setisInvalidValue(duplicateLabel);
} else if (findEmptyLabel()) {
setisInvalidValue("");
} else {
setisInvalidValue(null);
<div className="flex w-full space-x-2">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
onBlur={() => {
const duplicateLabel = findDuplicateLabel();
if (duplicateLabel) {
setisInvalidValue(duplicateLabel);
} else if (findEmptyLabel()) {
setisInvalidValue("");
} else {
setisInvalidValue(null);
}
}}
isInvalid={
(isInvalidValue === "" && choice.label.trim() === "") ||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
}
}}
isInvalid={
(isInvalidValue === "" && choice.label.trim() === "") ||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
}
/>
/>
{choice.id === "other" && (
<Input
id="otherInputLabel"
name="otherInputLabel"
value={question.otherOptionPlaceholder ?? "Please specify"}
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className={cn(choice.id === "other" && "border-dashed")}
onChange={(e) => {
if (e.target.value.trim() == "") e.target.value = "";
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
}}
/>
)}
</div>
{question.choices && question.choices.length > 2 && (
<TrashIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"

View File

@@ -216,30 +216,45 @@ export default function MultipleChoiceSingleForm({
<div className="mt-2 space-y-2" id="choices">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onBlur={() => {
const duplicateLabel = findDuplicateLabel();
if (duplicateLabel) {
setisInvalidValue(duplicateLabel);
} else if (findEmptyLabel()) {
setisInvalidValue("");
} else {
setisInvalidValue(null);
<div key={choiceIdx} className="flex w-full items-center">
<div className="flex w-full space-x-2">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onBlur={() => {
const duplicateLabel = findDuplicateLabel();
if (duplicateLabel) {
setisInvalidValue(duplicateLabel);
} else if (findEmptyLabel()) {
setisInvalidValue("");
} else {
setisInvalidValue(null);
}
}}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
isInvalid={
(isInvalidValue === "" && choice.label.trim() === "") ||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
}
}}
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
isInvalid={
(isInvalidValue === "" && choice.label.trim() === "") ||
(isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim())
}
/>
/>
{choice.id === "other" && (
<Input
id="otherInputLabel"
name="otherInputLabel"
value={question.otherOptionPlaceholder ?? "Please specify"}
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className={cn(choice.id === "other" && "border-dashed")}
onChange={(e) => {
if (e.target.value.trim() == "") e.target.value = "";
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
}}
/>
)}
</div>
{question.choices && question.choices.length > 2 && (
<TrashIcon
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"

View File

@@ -257,12 +257,12 @@ export default function ResponseOptionsCard({
surveyClosedMessage.subheading,
]);
const handleCheckMark = () => {
const toggleAutocomplete = () => {
if (autoComplete) {
const updatedSurvey = { ...localSurvey, autoComplete: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey = { ...localSurvey, autoComplete: 25 };
const updatedSurvey = { ...localSurvey, autoComplete: Math.max(25, responseCount + 5) };
setLocalSurvey(updatedSurvey);
}
};
@@ -310,7 +310,7 @@ export default function ResponseOptionsCard({
<AdvancedOptionToggle
htmlId="closeOnNumberOfResponse"
isChecked={autoComplete}
onToggle={handleCheckMark}
onToggle={toggleAutocomplete}
title="Close survey on response limit"
description="Automatically close the survey after a certain number of responses."
childBorder={true}>
@@ -411,7 +411,7 @@ export default function ResponseOptionsCard({
htmlId="singleUserSurveyOptions"
isChecked={!!localSurvey.singleUse?.enabled}
onToggle={handleSingleUseSurveyToggle}
title="Single-Use Survey Links"
title="Single-use survey links"
description="Allow only 1 response per survey link."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">
@@ -507,7 +507,7 @@ export default function ResponseOptionsCard({
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}
onToggle={handleProtectSurveyWithPinToggle}
title="Protect Survey with a PIN"
title="Protect survey with a PIN"
description="Only users who have the PIN can access the survey."
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">

View File

@@ -223,14 +223,6 @@ export default function SurveyMenuBar({
return false;
}
/*
Check whether the count for autocomplete responses is not less
than the current count of accepted response and also it is not set to 0
*/
if ((survey.autoComplete && responseCount >= survey.autoComplete) || survey?.autoComplete === 0) {
return false;
}
return true;
};

View File

@@ -1,143 +0,0 @@
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import type { TEnvironment } from "@formbricks/types/environment";
import { Badge } from "@formbricks/ui/Badge";
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const session = await getServerSession(authOptions);
const product = await getProductByEnvironmentId(environmentId);
const team = await getTeamByEnvironmentId(environmentId);
if (!session) {
throw new Error("Session not found");
}
if (!product) {
throw new Error("Product not found");
}
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const isSurveyCreationDeletionDisabled = isViewer;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const surveys = await getSurveys(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
if (surveys.length === 0) {
return (
<SurveyStarter
environmentId={environmentId}
environment={environment}
product={product}
user={session.user}
/>
);
}
return (
<>
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
{!isSurveyCreationDeletionDisabled && (
<Link href={`/environments/${environmentId}/surveys/templates`}>
<li className="col-span-1 h-56">
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
Create Survey
</div>
</div>
</li>
</Link>
)}
{surveys
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
.map((survey) => {
const isSingleUse = survey.singleUse?.enabled ?? false;
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
return (
<li key={survey.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
<div className="px-6 py-4">
<Badge
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
startIconClassName="mr-2"
text={
survey.type === "link"
? "Link Survey"
: survey.type === "web"
? "In-Product Survey"
: ""
}
type="gray"
size={"tiny"}
className="font-base"></Badge>
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
</div>
<Link
href={
survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
: `/environments/${environmentId}/surveys/${survey.id}/summary`
}
className="absolute h-full w-full"></Link>
<div className="divide-y divide-slate-100">
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
<div className="flex items-center">
{survey.status !== "draft" && (
<>
{(survey.type === "link" || environment.widgetSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
</>
)}
{survey.status === "draft" && (
<span className="text-xs italic text-slate-400">Draft</span>
)}
</div>
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment!}
webAppUrl={WEBAPP_URL}
singleUseId={singleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
/>
</div>
</div>
</div>
</li>
);
})}
</ul>
<UsageAttributesUpdater numSurveys={surveys.length} />
</>
);
}

View File

@@ -31,11 +31,12 @@ export default function SurveyStarter({
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;
const augmentedTemplate = {
const augmentedTemplate: TSurveyInput = {
...template.preset,
type: surveyType,
autoComplete,
} as TSurveyInput;
autoComplete: autoComplete || undefined,
createdBy: user.id,
};
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);

View File

@@ -1,18 +1,71 @@
import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import SurveysList from "./components/SurveyList";
import SurveysList from "@formbricks/ui/SurveysList";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const session = await getServerSession(authOptions);
const product = await getProductByEnvironmentId(params.environmentId);
const team = await getTeamByEnvironmentId(params.environmentId);
if (!session) {
throw new Error("Session not found");
}
if (!product) {
throw new Error("Product not found");
}
if (!team) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const surveys = await getSurveys(params.environmentId);
const environments = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} />
{surveys.length > 0 ? (
<SurveysList
environment={environment}
surveys={surveys}
otherEnvironment={otherEnvironment}
isViewer={isViewer}
WEBAPP_URL={WEBAPP_URL}
userId={session.user.id}
/>
) : (
<SurveyStarter
environmentId={params.environmentId}
environment={environment}
product={product}
user={session.user}
/>
)}
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -70,11 +70,12 @@ export default function TemplateList({
setLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;
const augmentedTemplate = {
const augmentedTemplate: TSurveyInput = {
...activeTemplate.preset,
type: surveyType,
autoComplete,
} as TSurveyInput;
createdBy: user.id,
};
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};

View File

@@ -2200,7 +2200,6 @@ export const templates: TTemplate[] = [
},
{
name: "Improve Newsletter Content",
category: "Growth",
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
description: "Find out how your subscribers like your newsletter content.",
@@ -2506,6 +2505,7 @@ export const minimalSurvey: TSurvey = {
name: "Minimal Survey",
type: "web",
environmentId: "someEnvId1",
createdBy: null,
status: "draft",
attributeFilters: [],
displayOption: "displayOnce",

View File

@@ -55,7 +55,7 @@ export default function Onboarding({ session, environmentId, user, product }: On
setIsLoading(true);
try {
const updatedProfile = { ...user, onboardingCompleted: true };
const updatedProfile = { onboardingCompleted: true };
await updateUserAction(updatedProfile);
if (environmentId) {

View File

@@ -1,6 +1,12 @@
import { Button } from "@formbricks/ui/Button";
const ContentLayout = ({ headline, description, children }) => {
interface ContentLayoutProps {
headline: string;
description: string;
children?: React.ReactNode;
}
const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => {
return (
<div className="flex h-screen">
<div className="m-auto flex flex-col gap-7 text-center text-slate-700">
@@ -57,9 +63,17 @@ export const ExpiredContent = () => {
return (
<ContentLayout
headline="Invite expired 😥"
description="Invites are valid for 7 days. Please request a new invite.">
<div></div>
</ContentLayout>
description="Invites are valid for 7 days. Please request a new invite."
/>
);
};
export const InvitationNotFound = () => {
return (
<ContentLayout
headline="Invite not found 😥"
description="The invitation code cannot be found or has already been used."
/>
);
};

View File

@@ -1,48 +1,54 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
import { env } from "@formbricks/lib/env.mjs";
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import {
ExpiredContent,
InvitationNotFound,
NotLoggedInContent,
RightAccountContent,
UsedContent,
WrongAccountContent,
} from "./components/InviteContentComponents";
export default async function JoinTeam({ searchParams }) {
const currentUser = await getServerSession(authOptions);
export default async function InvitePage({ searchParams }) {
const session = await getServerSession(authOptions);
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return <InvitationNotFound />;
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
if (!invite || isInviteExpired) {
if (isInviteExpired) {
return <ExpiredContent />;
} else if (invite.accepted) {
return <UsedContent />;
} else if (!currentUser) {
const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token;
} else if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
return <NotLoggedInContent email={email} token={searchParams.token} redirectUrl={redirectUrl} />;
} else if (currentUser.user?.email !== email) {
} else if (session.user?.email !== email) {
return <WrongAccountContent />;
} else {
await createMembership(invite.teamId, currentUser.user.id, { accepted: true, role: invite.role });
await createMembership(invite.teamId, session.user.id, { accepted: true, role: invite.role });
await deleteInvite(inviteId);
sendInviteAcceptedEmail(invite.creator.name ?? "", currentUser.user?.name ?? "", invite.creator.email);
sendInviteAcceptedEmail(invite.creator.name ?? "", session.user?.name ?? "", invite.creator.email);
return <RightAccountContent />;
}
} catch (e) {
return <ExpiredContent />;
console.error(e);
return <InvitationNotFound />;
}
}

View File

@@ -159,30 +159,27 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
return surveyFields;
};
const notificationFooter = () => {
const notificationFooter = (environmentId: string) => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
</div>
`;
};
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
return `
<p>Wed love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<br/>
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
${notificationFooter(notificationData.environmentId)}
`;
};
@@ -207,7 +204,7 @@ export const sendWeeklySummaryNotificationEmail = async (
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${notificationInsight(notificationData.insights)}
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
${notificationFooter()}
${notificationFooter(notificationData.environmentId)}
`),
});
};
@@ -231,7 +228,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
${createReminderNotificationBody(notificationData)}
`),
});
};

View File

@@ -7,6 +7,7 @@ import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { ZPipelineInput } from "@formbricks/types/pipelines";
@@ -125,6 +126,9 @@ export async function POST(request: Request) {
return false;
});
// Exclude current response
const responseCount = await getResponseCountBySurveyId(surveyId);
if (usersWithNotifications.length > 0) {
// get survey
if (!surveyData) {
@@ -155,7 +159,7 @@ export async function POST(request: Request) {
// send email to all users
await Promise.all(
usersWithNotifications.map(async (user) => {
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
})
);
}

View File

@@ -23,7 +23,11 @@ export async function POST(request: Request): Promise<NextResponse> {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
const country =
headers().get("CF-IPCountry") ||
headers().get("X-Vercel-IP-Country") ||
headers().get("CloudFront-Viewer-Country") ||
undefined;
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
if (!inputValidation.success) {

View File

@@ -46,7 +46,11 @@ export async function POST(request: Request, context: Context): Promise<NextResp
}
const agent = UAParser(request.headers.get("user-agent"));
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
const country =
headers().get("CF-IPCountry") ||
headers().get("X-Vercel-IP-Country") ||
headers().get("CloudFront-Viewer-Country") ||
undefined;
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!inputValidation.success) {

View File

@@ -2,18 +2,14 @@ import { NextResponse } from "next/server";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants";
import {
sendGettingStartedEmail,
sendInviteAcceptedEmail,
sendVerificationEmail,
} from "@formbricks/lib/emails/emails";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@formbricks/lib/emails/emails";
import { env } from "@formbricks/lib/env.mjs";
import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createTeam, getTeam } from "@formbricks/lib/team/service";
import { createUser } from "@formbricks/lib/user/service";
import { createUser, updateUser } from "@formbricks/lib/user/service";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
@@ -54,8 +50,6 @@ export async function POST(request: Request) {
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(user);
} else {
await sendGettingStartedEmail(user);
}
await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
@@ -82,14 +76,28 @@ export async function POST(request: Request) {
else {
const team = await createTeam({ name: user.name + "'s Team" });
await createMembership(team.id, user.id, { role: "owner", accepted: true });
await createProduct(team.id, { name: "My Product" });
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
}
// send verification email amd return user
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(user);
} else {
await sendGettingStartedEmail(user);
}
return NextResponse.json(user);
} catch (e) {
if (e.code === "P2002") {

View File

@@ -112,6 +112,14 @@ input[type="search"]::-ms-reveal {
display: none;
}
.surveyFilterDropdown[data-state="open"]{
background-color: #0f172a;
color: white;
}
.surveyFilterDropdown:hover * {
background-color: #0f172a;
color: white;
}
input[type='range']::-webkit-slider-thumb {
background: #0f172a;

View File

@@ -13,3 +13,6 @@ export const shareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
return regex.test(url);
};
export const isWebAppRoute = (url: string): boolean =>
url.startsWith("/environments") && url !== "/api/auth/signout";

View File

@@ -15,7 +15,11 @@ export default async function Home() {
redirect("/auth/login");
}
if (!ONBOARDING_DISABLED && session?.user && !session?.user?.onboardingCompleted) {
if (!session?.user) {
return <ClientLogout />;
}
if (!ONBOARDING_DISABLED && !session.user.onboardingCompleted) {
return redirect(`/onboarding`);
}

View File

@@ -27,7 +27,7 @@ export default function LegalFooter({
return (
<div
className={`fixed bottom-0 h-12 w-full`}
className={`absolute bottom-0 h-12 w-full`}
style={{
backgroundColor: `${bgColor}`,
}}>

View File

@@ -135,7 +135,7 @@ export default function LinkSurvey({
return (
<>
<ContentWrapper className="h-full w-full p-0 md:max-w-md">
<ContentWrapper className="my-12 h-full w-full p-0 md:max-w-md">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />

View File

@@ -195,7 +195,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
}
return survey ? (
<div>
<div className="relative">
<MediaBackground survey={survey}>
<LinkSurvey
survey={survey}

View File

@@ -6,14 +6,33 @@ import {
} from "@/app/middleware/bucket";
import {
clientSideApiRoute,
isWebAppRoute,
loginRoute,
shareUrlRoute,
signupRoute,
} from "@/app/middleware/endpointValidator";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
const loginUrl = new URL(
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
WEBAPP_URL
);
return NextResponse.redirect(loginUrl.href);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
}
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
}
@@ -54,5 +73,8 @@ export const config = {
"/api/v1/js/actions",
"/api/v1/client/storage",
"/share/(.*)/:path",
"/environments/:path*",
"/api/auth/signout",
"/auth/login",
],
};

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "1.5.0",
"version": "1.5.1",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -27,9 +27,9 @@
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-email/components": "^0.0.14",
"@sentry/nextjs": "^7.98.0",
"@sentry/nextjs": "^7.99.0",
"@vercel/og": "^0.6.2",
"@vercel/speed-insights": "^1.0.8",
"@vercel/speed-insights": "^1.0.9",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.1",
"encoding": "^0.1.13",
@@ -37,25 +37,25 @@
"googleapis": "^131.0.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.1.0",
"lucide-react": "^0.315.0",
"lru-cache": "^10.2.0",
"lucide-react": "^0.321.0",
"mime": "^4.0.1",
"next": "14.1.0",
"nodemailer": "^6.9.8",
"nodemailer": "^6.9.9",
"otplib": "^12.0.1",
"posthog-js": "^1.102.1",
"posthog-js": "^1.104.4",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^2.0.0",
"react-hook-form": "^7.49.3",
"react-hook-form": "^7.50.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.0.1",
"sharp": "^0.33.2",
"ua-parser-js": "^1.0.37",
"webpack": "^5.90.0",
"webpack": "^5.90.1",
"xlsx": "^0.18.5"
},
"devDependencies": {

View File

@@ -63,13 +63,14 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is visible
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
await page.waitForTimeout(1000);
});
test("Admin checks Display", async ({ page }) => {
await login(page, email, password);
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
await page.getByRole("link", { name: "In-app Open options Product" }).click();
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 1 Display
@@ -120,8 +121,7 @@ test.describe("JS Package Test", async () => {
test("Admin validates Response", async ({ page }) => {
await login(page, email, password);
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
await page.getByRole("link", { name: "In-app Open options Product" }).click();
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 2 Displays

View File

@@ -40,6 +40,7 @@ export const skipOnboarding = async (page: Page): Promise<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);

View File

@@ -1,43 +0,0 @@
Forget all previous instructions. You are the coding assistant "Formbricks Assistant".
- You help programmers find a solution to their questions and coding challenges. You give very brief and concise answers unless I the user prompt you to elaborate.
- Formbricks is the open-source go-to solution for in-product micro-surveys that is supercharging our users product experience!
- Formbricks uses Typescript, Next.Js, Next-auth, Prisma, TailwindCss, Radix UI
- When you are asked to generate documentation please have a playful but succinct writing style and return everything in escaped markdown.
- This is the prisma schema:
enum PipelineTriggers { responseCreated, responseUpdated, responseFinished }
model Webhook { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), url String, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, triggers PipelineTriggers[] }
model Attribute { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade), attributeClassId String, person Person @relation(fields: [personId], references: [id], onDelete: Cascade), personId String, value String, @@unique([attributeClassId, personId]) }
enum AttributeType { code, noCode, automatic }
model AttributeClass { 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 AttributeType, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, attributes Attribute[], attributeFilters SurveyAttributeFilter[], @@unique([name, environmentId]) }
model Person { id String @id @default(cuid()), 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[] }
model Response { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), finished Boolean @default(false), survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, person Person? @relation(fields: [personId], references: [id], onDelete: Cascade), personId String?, data Json @default("{}"), meta Json @default("{}") }
enum SurveyStatus { draft, inProgress, paused, completed, archived }
enum DisplayStatus { seen, responded }
model Display { 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, person Person? @relation(fields: [personId], references: [id], onDelete: Cascade), personId String?, status DisplayStatus @default(seen) }
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, @@unique([surveyId, eventClassId]) }
enum SurveyAttributeFilterCondition { equals, notEquals }
model SurveyAttributeFilter { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), attributeClass AttributeClass @relation(fields: [attributeClassId], references: [id], onDelete: Cascade), attributeClassId String, survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade), surveyId String, condition SurveyAttributeFilterCondition, value String, @@unique([surveyId, attributeClassId]) }
enum SurveyType { email, link, mobile, web }
enum displayOptions { displayOnce, displayMultiple, respondMultiple }
model Survey { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, type SurveyType @default(web), environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, status SurveyStatus @default(draft), questions Json @default("[]"), thankYouCard Json @default("{"enabled": false}"), responses Response[], displayOption displayOptions @default(displayOnce), recontactDays Int?, triggers SurveyTrigger[], attributeFilters SurveyAttributeFilter[], displays Display[], autoClose Int?, delay Int @default(0) }
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, properties Json @default("{}") }
enum EventType { code, noCode, automatic }
model EventClass { 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[], noCodeConfig Json?, environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String, surveys SurveyTrigger[], @@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"), person Person @relation(fields: [personId], references: [id], onDelete: Cascade), personId String, events Event[] }
enum EnvironmentType { production, development }
model Environment { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), type EnvironmentType, product Product @relation(fields: [productId], references: [id], onDelete: Cascade), productId String, widgetSetupCompleted Boolean @default(false), surveys Survey[], people Person[], eventClasses EventClass[], attributeClasses AttributeClass[], apiKeys ApiKey[], webhooks Webhook[] }
model Product { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, environments Environment[], brandColor String @default("#64748b"), recontactDays Int @default(7), formbricksSignature Boolean @default(true) }
enum Plan { free, pro }
model Team { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String, memberships Membership[], products Product[], plan Plan @default(free), stripeCustomerId String?, invites Invite[] }
enum MembershipRole { owner, admin, editor, developer, viewer }
model Membership { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, user User @relation(fields: [userId], references: [id], onDelete: Cascade), userId String, accepted Boolean @default(false), role MembershipRole, @@id([userId, teamId]) }
model Invite { id String @id @default(uuid()), email String, name String?, team Team @relation(fields: [teamId], references: [id], onDelete: Cascade), teamId String, creator User @relation("inviteCreatedBy", fields: [creatorId], references: [id]), creatorId String, acceptor User? @relation("inviteAcceptedBy", fields: [acceptorId], references: [id], onDelete: Cascade), acceptorId String?, accepted Boolean @default(false), createdAt DateTime @default(now()), expiresAt DateTime, role MembershipRole @default(admin), @@index([email, teamId], name: "email_teamId_unique") }
model ApiKey { id String @id @unique @default(cuid()), createdAt DateTime @default(now()), lastUsedAt DateTime?, label String?, hashedKey String @unique(), environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade), environmentId String }
enum IdentityProvider { email, github, google }
model Account { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), user User? @relation(fields: [userId], references: [id], onDelete: Cascade), userId String, type String, provider String, providerAccountId String, access_token String? @db.Text, refresh_token String? @db.Text, expires_at Int?, token_type String?, scope String?, id_token String? @db.Text, session_state String?, @@unique([provider, providerAccountId]) }
enum Role { project_manager, engineer, founder, marketing_specialist, other }
enum Objective { increase_conversion, improve_user_retention, increase_user_adoption, sharpen_marketing_messaging, support_sales, other }
enum Intention { survey_user_segments, survey_at_specific_point_in_user_journey, enrich_customer_profiles, collect_all_user_feedback_on_one_platform, other }
model User { id String @id @default(cuid()), createdAt DateTime @default(now()) @map(name: "created_at"), updatedAt DateTime @updatedAt @map(name: "updated_at"), name String?, email String @unique, emailVerified DateTime? @map(name: "email_verified"), password String?, onboardingCompleted Boolean @default(false), identityProvider IdentityProvider @default(email), identityProviderAccountId String?, memberships Membership[], accounts Account[], groupId String?, invitesCreated Invite[] @relation("inviteCreatedBy"), invitesAccepted Invite[] @relation("inviteAcceptedBy"), role Role?, notificationSettings Json @default("{}") }
Please respond with “Formbricks Assistant is now ready! How can I help?” when you read everything.

View File

@@ -32,13 +32,13 @@
"prepare": "husky install"
},
"devDependencies": {
"@playwright/test": "^1.41.1",
"@playwright/test": "^1.41.2",
"eslint-config-formbricks": "workspace:*",
"husky": "^9.0.5",
"lint-staged": "^15.2.0",
"husky": "^9.0.10",
"lint-staged": "^15.2.1",
"rimraf": "^5.0.5",
"tsx": "^4.7.0",
"turbo": "^1.11.3"
"turbo": "^1.12.2"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -64,6 +64,6 @@
},
"dependencies": {
"@changesets/cli": "^2.27.1",
"playwright": "^1.41.1"
"playwright": "^1.41.2"
}
}

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "createdBy" TEXT;
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "Survey" DROP CONSTRAINT "Survey_createdBy_fkey";
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -25,15 +25,15 @@
"predev": "pnpm generate"
},
"dependencies": {
"@prisma/client": "^5.8.1",
"@prisma/extension-accelerate": "^0.6.2",
"@prisma/client": "^5.9.1",
"@prisma/extension-accelerate": "^0.6.3",
"dotenv-cli": "^7.3.0"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"prisma": "^5.8.1",
"prisma": "^5.9.1",
"prisma-dbml-generator": "^0.10.0",
"prisma-json-types-generator": "^3.0.3",
"zod": "^3.22.4",

View File

@@ -257,6 +257,8 @@ model Survey {
type SurveyType @default(web)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
creator User? @relation(fields: [createdBy], references: [id])
createdBy String?
status SurveyStatus @default(draft)
/// @zod.custom(imports.ZSurveyWelcomeCard)
/// [SurveyWelcomeCard]
@@ -564,6 +566,7 @@ model User {
/// @zod.custom(imports.ZUserNotificationSettings)
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
surveys Survey[]
@@index([email])
}

View File

@@ -31,17 +31,12 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
<div>
<Label>Role</Label>
<Select
defaultValue="admin"
value={value}
onValueChange={(v) => onChange(v as MembershipRole)}
disabled={!canDoRoleManagement}>
<SelectTrigger className="capitalize">
<SelectValue
placeholder={
<span className="text-slate-400">
{canDoRoleManagement ? "Select role" : "Select role (Pro Feature)"}
</span>
}
/>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.5.0",
"version": "1.5.1",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
@@ -40,17 +40,17 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/preset-env": "^7.23.8",
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.11",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"isomorphic-fetch": "^3.0.0",

View File

@@ -10,7 +10,6 @@ import { prisma } from "@formbricks/database";
import { createAccount } from "./account/service";
import { verifyPassword } from "./auth/util";
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
import { sendGettingStartedEmail } from "./emails/emails";
import { env } from "./env.mjs";
import { verifyToken } from "./jwt";
import { createMembership } from "./membership/service";
@@ -114,7 +113,6 @@ export const authOptions: NextAuthOptions = {
}
user = await updateUser(user.id, { emailVerified: new Date() });
await sendGettingStartedEmail(user);
return user;
},
@@ -223,7 +221,7 @@ export const authOptions: NextAuthOptions = {
identityProvider: provider,
identityProviderAccountId: account.providerAccountId,
});
await sendGettingStartedEmail(userProfile);
// Default team assignment if env variable is set
if (env.DEFAULT_TEAM_ID && env.DEFAULT_TEAM_ID.length > 0) {
// check if team exists
@@ -250,7 +248,22 @@ export const authOptions: NextAuthOptions = {
...account,
userId: userProfile.id,
});
await createProduct(team.id, { name: "My Product" });
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...userProfile.notificationSettings,
alert: {
...userProfile.notificationSettings?.alert,
},
weeklySummary: {
...userProfile.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(userProfile.id, {
notificationSettings: updatedNotificationSettings,
});
return true;
}
}

View File

@@ -13,6 +13,7 @@ import {
} from "../constants";
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
import { getQuestionResponseMapping } from "../responses";
import { getTeamByEnvironmentId } from "../team/service";
import { withEmailTemplate } from "./email-template";
const nodemailer = require("nodemailer");
@@ -33,10 +34,6 @@ interface TEmailUser {
email: string;
}
interface TEmailUserWithName extends TEmailUser {
name: string | null;
}
export interface LinkSurveyEmailData {
surveyId: string;
email: string;
@@ -96,34 +93,6 @@ export const sendVerificationEmail = async (user: TEmailUser) => {
});
};
export const sendGettingStartedEmail = async (user: TEmailUserWithName) => {
await sendEmail({
to: user.email,
subject: "Get started with Formbricks 🤸",
html: withEmailTemplate(`
<h1 style="text-align: center; line-height: 1.2; padding-top: 16px; padding-bottom:8px;">Turn customer insights into irresistible experiences</h1>
<a href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=top_image"><img src="https://formbricks-cdn.s3.eu-central-1.amazonaws.com/getting-started-with-formbricks-v5.png" alt="Formbricks can do it all" /></a>
<h3 style="text-align:center;">Welcome to Formbricks! 🤗</h3>
<p style="text-align:center;">We're the fastest growing Experience Management platform! Gracefully collect feedback without survey fatigue. Are you ready?</p>
<div style="text-align:center; margin-bottom:72px;">
<a class="button" href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=first_button">Create your survey</a><br/>
</div>
<a href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=second_image"><img style="border-radius:16px; box-shadow: 10px 10px 57px -21px rgba(71,85,105,0.58);" src="https://formbricks-cdn.s3.eu-central-1.amazonaws.com/getting-started-header-v4.png" alt="Formbricks can do it all"></a>
<h2 style="margin-top:32px;">Collect feedback everywhere!</h2>
<p>Formbricks is very versatile. Run:</p>
<ul>
<li><b>Website Surveys</b> like HotJar Ask</li>
<li><b>In-App Surveys</b> like Sprig</li>
<li><b>Link Surveys</b> like Typeform</li>
<li><b>Headless Surveys</b> via API</li>
</ul>
<p>All on one, open source platform ✅</p>
<a class="button" style="margin-bottom:12px; margin-top:0px;" href="https://app.formbricks.com?utm_source=drip_campaign&utm_medium=email&utm_campaign=first_drip_mail&utm_content=second_button">Create your survey</a><br/>
<p style="margin-bottom:0px; margin-top:40px; text-align:center;"><b>Life is short, craft something irresistible!</b><br/>The Formbricks Team 🤍</p>
`),
});
};
export const sendForgotPasswordEmail = async (user: TEmailUser) => {
const token = createToken(user.id, user.email, {
expiresIn: "1d",
@@ -193,44 +162,52 @@ export const sendResponseFinishedEmail = async (
email: string,
environmentId: string,
survey: { id: string; name: string; questions: TSurveyQuestion[] },
response: TResponse
response: TResponse,
responseCount: number
) => {
const personEmail = response.person?.attributes["email"];
const team = await getTeamByEnvironmentId(environmentId);
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail?.toString() || MAIL_FROM,
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>
html: withEmailTemplate(`
<h1>Hey 👋</h1>
<p>Congrats, you received a new response to your survey!
Someone just completed your survey <strong>${survey.name}</strong><br/></p>
<hr/>
<hr/>
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">View all responses</a>
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<hr/>
<p><b>Don't want to get these emails?</b></p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>. <br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</i></p></div>
`),
});
};

View File

@@ -121,7 +121,7 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvite = async (inviteId: string): Promise<InviteWithCreator> => {
export const getInvite = async (inviteId: string): Promise<InviteWithCreator | null> => {
const invite = await unstable_cache(
async () => {
validateInputs([inviteId, ZString]);
@@ -140,18 +140,18 @@ export const getInvite = async (inviteId: string): Promise<InviteWithCreator> =>
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
return invite;
},
[`getInvite-${inviteId}`],
{ tags: [inviteCache.tag.byId(inviteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
return {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
};
return invite
? {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
}
: null;
};
export const resendInvite = async (inviteId: string): Promise<TInvite> => {

View File

@@ -14,9 +14,9 @@
"test": "jest -ci --coverage --no-cache"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "3.499.0",
"@aws-sdk/client-s3": "3.499.0",
"@aws-sdk/s3-request-presigner": "3.499.0",
"@aws-sdk/s3-presigned-post": "3.504.0",
"@aws-sdk/client-s3": "3.504.0",
"@aws-sdk/s3-request-presigner": "3.504.0",
"@t3-oss/env-nextjs": "^0.8.0",
"@formbricks/api": "*",
"@formbricks/database": "*",
@@ -27,10 +27,10 @@
"jsonwebtoken": "^9.0.2",
"markdown-it": "^14.0.0",
"mime-types": "^2.1.35",
"nanoid": "^5.0.4",
"nanoid": "^5.0.5",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.8",
"posthog-node": "^3.6.0",
"nodemailer": "^6.9.9",
"posthog-node": "^3.6.1",
"server-only": "^0.0.1",
"tailwind-merge": "^2.2.1"
},

View File

@@ -357,7 +357,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
});
if (!responsePrisma) {
throw new ResourceNotFoundError("Response", responseId);
return null;
}
const response: TResponse = {
@@ -382,10 +382,12 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
)();
return {
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse;
return response
? ({
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse)
: null;
};
export const getResponses = async (

View File

@@ -280,7 +280,8 @@ describe("Tests for getResponse service", () => {
it("Throws ResourceNotFoundError if no response is found", async () => {
prismaMock.response.findUnique.mockResolvedValue(null);
await expect(getResponse(mockResponse.id)).rejects.toThrow(ResourceNotFoundError);
const response = await getResponse(mockResponse.id);
expect(response).toBeNull();
});
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {

View File

@@ -20,6 +20,7 @@ import { personCache } from "../person/cache";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
@@ -31,6 +32,7 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
createdBy: true,
status: true,
welcomeCard: true,
questions: true,
@@ -410,6 +412,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
@@ -489,17 +492,30 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
const actionClasses = await getActionClasses(environmentId);
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
}
// TODO: Create with triggers & attributeFilters
delete surveyBody.triggers;
delete surveyBody.attributeFilters;
const data: Omit<TSurveyInput, "triggers" | "attributeFilters"> = {
const createdBy = surveyBody.createdBy;
delete surveyBody.createdBy;
const data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...surveyBody,
// TODO: Create with triggers & attributeFilters
triggers: undefined,
attributeFilters: undefined,
};
if (surveyBody.type === "web" && data.thankYouCard) {
data.thankYouCard.buttonLabel = "";
data.thankYouCard.buttonLink = "";
}
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -517,6 +533,8 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
};
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,
@@ -525,7 +543,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
return transformedSurvey;
};
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
export const duplicateSurvey = async (environmentId: string, surveyId: string, userId: string) => {
validateInputs([environmentId, ZId], [surveyId, ZId]);
const existingSurvey = await getSurvey(surveyId);
@@ -546,6 +564,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
@@ -563,6 +582,11 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
id: environmentId,
},
},
creator: {
connect: {
id: userId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
: Prisma.JsonNull,

View File

@@ -12,6 +12,8 @@ import {
TSurveyQuestionType,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
import { TTeam } from "@formbricks/types/teams";
import { TUser } from "@formbricks/types/user";
import { selectSurvey } from "../service";
@@ -57,6 +59,38 @@ export const mockDisplay = {
status: null,
};
// id: true,
// name: true,
// email: true,
// emailVerified: true,
// imageUrl: true,
// createdAt: true,
// updatedAt: true,
// onboardingCompleted: true,
// twoFactorEnabled: true,
// identityProvider: true,
// objective: true,
// notificationSettings: true,
export const mockUser: TUser = {
id: mockId,
name: "mock User",
email: "test@unit.com",
emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
onboardingCompleted: true,
twoFactorEnabled: false,
identityProvider: "google",
objective: "improve_user_retention",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedTeamIds: [],
},
};
export const mockPerson: TPerson = {
id: mockId,
userId: mockId,
@@ -127,6 +161,30 @@ const baseSurveyProperties = {
...commonMockProperties,
};
export const mockTeamOutput: TTeam = {
id: mockId,
name: "mock Team",
createdAt: currentDate,
updatedAt: currentDate,
billing: {
stripeCustomerId: null,
features: {
inAppSurvey: {
status: "inactive",
unlimited: false,
},
linkSurvey: {
status: "inactive",
unlimited: false,
},
userTargeting: {
status: "inactive",
unlimited: false,
},
},
},
};
export const mockSurveyOutput: SurveyMock = {
type: "web",
status: "inProgress",
@@ -136,6 +194,7 @@ export const mockSurveyOutput: SurveyMock = {
singleUse: null,
styling: null,
displayPercentage: null,
createdBy: null,
pin: null,
resultShareKey: null,
...baseSurveyProperties,
@@ -159,6 +218,7 @@ export const updateSurveyInput: TSurvey = {
styling: null,
singleUse: null,
displayPercentage: null,
createdBy: null,
pin: null,
resultShareKey: null,
...commonMockProperties,

View File

@@ -24,9 +24,11 @@ import {
mockProduct,
mockSurveyOutput,
mockSurveyWithAttributesOutput,
mockTeamOutput,
mockTransformedSurveyOutput,
mockTransformedSurveyWithAttributesIdOutput,
mockTransformedSurveyWithAttributesOutput,
mockUser,
updateSurveyInput,
} from "./survey.mock";
@@ -235,6 +237,27 @@ describe("Tests for createSurvey", () => {
describe("Happy Path", () => {
it("Creates a survey successfully", async () => {
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
prismaMock.user.findMany.mockResolvedValueOnce([
{
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
},
]);
prismaMock.user.update.mockResolvedValueOnce({
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
});
const createdSurvey = await createSurvey(mockId, createSurveyInput);
expect(createdSurvey).toEqual(mockTransformedSurveyWithAttributesIdOutput);
});
@@ -260,7 +283,7 @@ describe("Tests for duplicateSurvey", () => {
it("Duplicates a survey successfully", async () => {
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
const createdSurvey = await duplicateSurvey(mockId, mockId);
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
expect(createdSurvey).toEqual(mockSurveyWithAttributesOutput);
});
});
@@ -270,13 +293,13 @@ describe("Tests for duplicateSurvey", () => {
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
prismaMock.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
});
it("should throw an error if there is an unknown error", async () => {
const mockErrorMessage = "Unknown error occurred";
prismaMock.survey.create.mockRejectedValue(new Error(mockErrorMessage));
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(Error);
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(Error);
});
});
});

View File

@@ -15,10 +15,12 @@ import {
ZTeam,
ZTeamCreateInput,
} from "@formbricks/types/teams";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { environmentCache } from "../environment/cache";
import { getProducts } from "../product/service";
import { getUsersWithTeam, updateUser } from "../user/service";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { teamCache } from "./cache";
@@ -396,3 +398,38 @@ export const getTeamBillingInfo = async (teamId: string): Promise<TTeamBilling |
tags: [teamCache.tag.byId(teamId)],
}
)();
export const subscribeTeamMembersToSurveyResponses = async (
environmentId: string,
surveyId: string
): Promise<void> => {
try {
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new ResourceNotFoundError("Team", environmentId);
}
const users = await getUsersWithTeam(team.id);
await Promise.all(
users.map((user) => {
if (!user.notificationSettings?.unsubscribedTeamIds?.includes(team?.id as string)) {
const defaultSettings = { alert: {}, weeklySummary: {} };
const updatedNotificationSettings: TUserNotificationSettings = {
...defaultSettings,
...user.notificationSettings,
};
updatedNotificationSettings.alert[surveyId] = true;
return updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
}
return Promise.resolve();
})
);
} catch (error) {
throw error;
}
};

View File

@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"downlevelIteration": true
}
"downlevelIteration": true,
},
}

View File

@@ -30,6 +30,7 @@ const responseSelection = {
twoFactorEnabled: true,
identityProvider: true,
objective: true,
notificationSettings: true,
};
// function to retrive basic information about a user's user
@@ -173,9 +174,9 @@ export const createUser = async (data: TUserCreateInput): Promise<TUser> => {
});
// send new user customer.io to customer.io
createCustomerIoCustomer(user).catch(error => {
console.error("Error sending user to CustomerIO:", error);
});
createCustomerIoCustomer(user);
return user;
};
// function to delete a user's user including teams
@@ -229,3 +230,38 @@ export const deleteUser = async (id: string): Promise<TUser> => {
throw error;
}
};
export const getUsersWithTeam = async (teamId: string): Promise<TUser[]> => {
validateInputs([teamId, ZId]);
const users = await prisma.user.findMany({
where: {
memberships: {
some: {
teamId,
},
},
},
select: responseSelection,
});
return users;
};
export const userIdRelatedToApiKey = async (apiKey: string) => {
const userId = await prisma.apiKey.findUnique({
where: { id: apiKey },
select: {
environment: {
select: {
people: {
select: {
userId: true,
},
},
},
},
},
});
return userId;
};

View File

@@ -0,0 +1,40 @@
import cuid2 from "@paralleldrive/cuid2";
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "../../lib/crypto";
import { env } from "../../lib/env.mjs";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = cuid2.createId();
if (!isEncrypted) {
return cuid;
}
const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY);
return encryptedCuid;
};
// validate the survey single use id
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
try {
let decryptedCuid: string | null = null;
if (surveySingleUseId.length === 64) {
if (!env.FORMBRICKS_ENCRYPTION_KEY) {
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
}
decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
} else {
decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
}
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;
}
} catch (error) {
return undefined;
}
};

View File

@@ -8,7 +8,7 @@
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"prettier": "^3.2.4",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/surveys",
"license": "MIT",
"version": "1.5.0",
"version": "1.5.1",
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -231,7 +231,7 @@ export default function MultipleChoiceMultiQuestion({
}, 100);
}
}}
placeholder="Please specify"
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}

View File

@@ -182,7 +182,7 @@ export default function MultipleChoiceSingleQuestion({
}, 100);
}
}}
placeholder="Please specify"
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}

View File

@@ -94,7 +94,7 @@ export default function OpenTextQuestion({
onSubmit({ [question.id]: value, inputType: question.inputType }, updatedttc);
}
}}
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
pattern={question.inputType === "phone" ? "[0-9+ ]+" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
/>
) : (

View File

@@ -7,8 +7,8 @@
"clean": "rimraf node_modules dist turbo"
},
"devDependencies": {
"@types/node": "20.11.6",
"@types/react": "18.2.48",
"@types/node": "20.11.16",
"@types/react": "18.2.52",
"@types/react-dom": "18.2.18",
"typescript": "^5.3.3"
}

View File

@@ -277,6 +277,7 @@ export const ZSurveyMultipleChoiceSingleQuestion = ZSurveyQuestionBase.extend({
choices: z.array(ZSurveyChoice),
logic: z.array(ZSurveyMultipleChoiceSingleLogic).optional(),
shuffleOption: z.enum(["none", "all", "exceptLast"]).optional(),
otherOptionPlaceholder: z.string().optional(),
});
export type TSurveyMultipleChoiceSingleQuestion = z.infer<typeof ZSurveyMultipleChoiceSingleQuestion>;
@@ -286,6 +287,7 @@ export const ZSurveyMultipleChoiceMultiQuestion = ZSurveyQuestionBase.extend({
choices: z.array(ZSurveyChoice),
logic: z.array(ZSurveyMultipleChoiceMultiLogic).optional(),
shuffleOption: z.enum(["none", "all", "exceptLast"]).optional(),
otherOptionPlaceholder: z.string().optional(),
});
export type TSurveyMultipleChoiceMultiQuestion = z.infer<typeof ZSurveyMultipleChoiceMultiQuestion>;
@@ -414,6 +416,7 @@ export const ZSurvey = z.object({
name: z.string(),
type: ZSurveyType,
environmentId: z.string(),
createdBy: z.string().nullable(),
status: ZSurveyStatus,
attributeFilters: z.array(ZSurveyAttributeFilter),
displayOption: ZSurveyDisplayOption,
@@ -441,6 +444,7 @@ export const ZSurvey = z.object({
export const ZSurveyInput = z.object({
name: z.string(),
type: ZSurveyType.optional(),
createdBy: z.string().cuid().optional(),
status: ZSurveyStatus.optional(),
displayOption: ZSurveyDisplayOption.optional(),
autoClose: z.number().optional(),
@@ -460,11 +464,13 @@ export const ZSurveyInput = z.object({
});
export type TSurvey = z.infer<typeof ZSurvey>;
export type TSurveyDates = {
createdAt: TSurvey["createdAt"];
updatedAt: TSurvey["updatedAt"];
closeOnDate: TSurvey["closeOnDate"];
};
export type TSurveyInput = z.infer<typeof ZSurveyInput>;
export const ZSurveyTSurveyQuestionType = z.union([

View File

@@ -13,6 +13,14 @@ export const ZUserObjective = z.enum([
export type TUserObjective = z.infer<typeof ZUserObjective>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
unsubscribedTeamIds: z.array(z.string()).optional(),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
export const ZUser = z.object({
id: z.string(),
name: z.string().nullable(),
@@ -25,6 +33,7 @@ export const ZUser = z.object({
updatedAt: z.date(),
onboardingCompleted: z.boolean(),
objective: ZUserObjective.nullable(),
notificationSettings: ZUserNotificationSettings,
});
export type TUser = z.infer<typeof ZUser>;
@@ -37,6 +46,7 @@ export const ZUserUpdateInput = z.object({
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().url().nullish(),
notificationSettings: ZUserNotificationSettings.optional(),
});
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
@@ -53,10 +63,3 @@ export const ZUserCreateInput = z.object({
});
export type TUserCreateInput = z.infer<typeof ZUserCreateInput>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;

View File

@@ -63,7 +63,7 @@ export const Button: React.ForwardRefExoticComponent<
// different styles depending on size
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-md",
size === "base" && "px-6 py-3 text-sm font-medium rounded-md",
size === "lg" && "px-4 py-2 text-base font-medium rounded-md",
size === "lg" && "px-8 py-4 text-base font-medium rounded-md",
size === "icon" &&
"w-10 h-10 justify-center group p-2 border rounded-lg border-transparent text-neutral-400 hover:border-slate-200 transition",
// turn button into a floating action button (fab)

View File

@@ -1,11 +1,13 @@
"use client";
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
import { CheckIcon, PauseIcon, PencilIcon } from "lucide-react";
import { TSurvey } from "@formbricks/types/surveys";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
interface SurveyStatusIndicatorProps {
status: string;
status: TSurvey["status"];
tooltip?: boolean;
}
@@ -31,9 +33,9 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "archived" && (
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
{status === "draft" && (
<div className=" rounded-full bg-slate-200 p-1">
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
)}
</TooltipTrigger>
@@ -61,13 +63,6 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
</div>
) : status === "archived" ? (
<div className="flex items-center space-x-2">
<span>Survey archived.</span>
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
</div>
</div>
) : null}
</div>
</TooltipContent>
@@ -84,18 +79,18 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
</span>
)}
{status === "paused" && (
<div className=" rounded-full bg-slate-300 p-1">
<div className="rounded-full bg-slate-300 p-1">
<PauseIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "completed" && (
<div className=" rounded-full bg-slate-200 p-1">
<div className="rounded-full bg-slate-200 p-1">
<CheckIcon className="h-3 w-3 text-slate-600" />
</div>
)}
{status === "archived" && (
<div className=" rounded-full bg-slate-300 p-1">
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
{status === "draft" && (
<div className="rounded-full bg-slate-300 p-1">
<PencilIcon className="h-3 w-3 text-slate-600" />
</div>
)}
</span>

View File

@@ -0,0 +1,211 @@
"use server";
import { Prisma } from "@prisma/client";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { prisma } from "../../database/src";
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
return duplicatedSurvey;
}
export async function copyToOtherEnvironmentAction(
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
session.user.id,
environmentId
);
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
session.user.id,
targetEnvironmentId
);
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
environmentId,
},
include: {
triggers: {
include: {
actionClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
where: {
name: trigger.actionClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.actionClass.create({
data: {
name: trigger.actionClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.actionClass.description,
type: trigger.actionClass.type,
noCodeConfig: trigger.actionClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
: undefined,
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
createdBy: undefined,
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((actionClassId) => ({
actionClassId: actionClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
creator: {
connect: {
id: session.user.id,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? Prisma.JsonNull,
singleUse: existingSurvey.singleUse ?? Prisma.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? Prisma.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? Prisma.JsonNull,
styling: existingSurvey.styling ?? Prisma.JsonNull,
},
});
surveyCache.revalidate({
id: newSurvey.id,
environmentId: targetEnvironmentId,
});
return newSurvey;
}
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const survey = await getSurvey(surveyId);
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
await deleteSurvey(surveyId);
};
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return generateSurveySingleUseId(isEncrypted);
}

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