mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-10 01:32:32 -05:00
Merge branch 'main' of github.com:formbricks/formbricks into Validation-for-Reset-Password
This commit is contained in:
@@ -46,6 +46,7 @@ NEXTAUTH_URL=http://localhost:3000
|
||||
# MAIL_FROM=noreply@example.com
|
||||
# SMTP_HOST=localhost
|
||||
# SMTP_PORT=1025
|
||||
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
# SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
|
||||
# SMTP_USER=smtpUser
|
||||
# SMTP_PASSWORD=smtpPassword
|
||||
|
||||
12
.env.example
12
.env.example
@@ -82,12 +82,6 @@ NEXT_PUBLIC_PRIVACY_URL=
|
||||
NEXT_PUBLIC_TERMS_URL=
|
||||
NEXT_PUBLIC_IMPRINT_URL=
|
||||
|
||||
# Disable Sentry warning
|
||||
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
|
||||
|
||||
# Enable Sentry Error Tracking
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Configure Github Login
|
||||
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
|
||||
GITHUB_ID=
|
||||
@@ -100,7 +94,6 @@ GOOGLE_CLIENT_SECRET=
|
||||
|
||||
|
||||
# Stripe Billing Variables
|
||||
NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID=
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
@@ -108,4 +101,7 @@ STRIPE_WEBHOOK_SECRET=
|
||||
# Configure Formbricks usage within Formbricks
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
||||
|
||||
# Cron Secret
|
||||
CRON_SECRET=
|
||||
23
.github/workflows/cron-weeklySummary.yml
vendored
Normal file
23
.github/workflows/cron-weeklySummary.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Cron - weeklySummary
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
|
||||
- cron: "0 8 * * 1"
|
||||
jobs:
|
||||
cron-weeklySummary:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
|
||||
--fail
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!packages/database/.env
|
||||
!apps/web/.env
|
||||
|
||||
# Prisma generated files
|
||||
packages/database/zod
|
||||
|
||||
1
.vercelignore
Normal file
1
.vercelignore
Normal file
@@ -0,0 +1 @@
|
||||
apps/web/.env
|
||||
@@ -5,10 +5,9 @@ import CrowdLogoDark from "@/images/clients/crowd-logo-dark.svg";
|
||||
import CrowdLogoLight from "@/images/clients/crowd-logo-light.svg";
|
||||
import NILogoDark from "@/images/clients/niLogoDark.svg";
|
||||
import NILogoLight from "@/images/clients/niLogoWhite.svg";
|
||||
import StackOceanLogoDark from "@/images/clients/stack-ocean-dark.png";
|
||||
import StackOceanLogoLight from "@/images/clients/stack-ocean-light.png";
|
||||
import AnimationFallback from "@/public/animations/fallback-image-open-source-feedback-software.jpg";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { usePlausible } from "next-plausible";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -19,8 +18,15 @@ export const Hero: React.FC = ({}) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="px-4 py-20 text-center sm:px-6 lg:px-8 lg:py-28">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<div className="px-4 pb-20 pt-16 text-center sm:px-6 lg:px-8 lg:pb-32 lg:pt-20">
|
||||
<a
|
||||
href="https://github.com/formbricks/formbricks"
|
||||
target="_blank"
|
||||
className="border-brand-dark rounded-full border px-6 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
|
||||
We're Open-Source | Star us on GitHub{" "}
|
||||
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
|
||||
</a>
|
||||
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<span className="xl:inline">Create Products People Remember</span>
|
||||
</h1>
|
||||
|
||||
@@ -29,7 +35,7 @@ export const Hero: React.FC = ({}) => {
|
||||
<br />
|
||||
<span className="hidden md:block">
|
||||
Continuously gather deep user insights,{" "}
|
||||
<span className="decoration-brand-dark underline underline-offset-4">all privacy-first.</span>
|
||||
<span className="decoration-brand-dark underline underline-offset-4">privacy-first.</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -37,7 +43,7 @@ export const Hero: React.FC = ({}) => {
|
||||
<p className="hidden whitespace-nowrap pt-3 text-xs text-slate-400 dark:text-slate-500 md:block">
|
||||
Trusted by
|
||||
</p>
|
||||
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-5">
|
||||
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-4">
|
||||
<Image
|
||||
src={CalLogoLight}
|
||||
alt="Cal Logo"
|
||||
@@ -80,18 +86,6 @@ export const Hero: React.FC = ({}) => {
|
||||
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
|
||||
width={200}
|
||||
/>
|
||||
<Image
|
||||
src={StackOceanLogoLight}
|
||||
alt="StackOcean Logo"
|
||||
className="block pb-1 hover:opacity-100 dark:hidden md:opacity-50"
|
||||
width={200}
|
||||
/>
|
||||
<Image
|
||||
src={StackOceanLogoDark}
|
||||
alt="StakcOcean Logo"
|
||||
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
|
||||
width={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden pt-10 md:block">
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function BestPracticeNavigation() {
|
||||
<div className=" mx-auto grid grid-cols-1 gap-6 px-2 sm:grid-cols-3">
|
||||
{BestPractices.map((bestPractice) => (
|
||||
<Link href={bestPractice.href} key={bestPractice.name}>
|
||||
<div className="drop-shadow-card duration-120 relative rounded-lg bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
|
||||
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative rounded-lg border border-slate-100 bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
|
||||
<div
|
||||
className={clsx(
|
||||
// base styles independent what type of button it is
|
||||
|
||||
@@ -37,6 +37,7 @@ const tiers = [
|
||||
features: [
|
||||
"Unlimited surveys",
|
||||
"Unlimited team members",
|
||||
"Remove branding",
|
||||
"Granular targeting",
|
||||
"In-product surveys",
|
||||
"Link surveys",
|
||||
@@ -57,8 +58,8 @@ const tiers = [
|
||||
discounted: false,
|
||||
highlight: false,
|
||||
description: "All features included. Unlimited usage.",
|
||||
features: ["All features of Free plan", "Unlimited responses", "Remove branding"],
|
||||
ctaName: "Sign up now",
|
||||
features: ["Unlimited responses per survey"],
|
||||
ctaName: "Start for free",
|
||||
plausibleGoal: "Pricing_CTA_ProPlan",
|
||||
},
|
||||
];
|
||||
@@ -145,7 +146,7 @@ export default function Pricing() {
|
||||
{tier.ctaName}
|
||||
</Button>
|
||||
|
||||
{tier.name === "Free" && (
|
||||
{tier.name !== "Self-hosting" && (
|
||||
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 248 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,122 @@
|
||||
import Image from "next/image";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
|
||||
import Demo from "./our-experience-github-acc-demo-screenshot.png";
|
||||
import Mail from "./github-accelerator-selection-mail.png";
|
||||
import Teams from "./github-accelerator-2022-teams.png";
|
||||
import NewsletterSignup from "@/components/shared/NewsletterSignup";
|
||||
|
||||
export const meta = {
|
||||
title: "Our GitHub Accelerator Experience 👀",
|
||||
description:
|
||||
"What we learned during the first GitHub Open-Source Accelerator Programm - our experience and if we would do it again.",
|
||||
date: "2023-04-13",
|
||||
};
|
||||
|
||||
_We were among the first 20 teams ever to run through the Open-Source Accelerator by Github. Read about our experience and if we would do it again:_
|
||||
|
||||
<Image
|
||||
src={Demo}
|
||||
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
## Hey there,
|
||||
|
||||
In December of last year, we completed a rather brief questionnaire to apply for the inaugural batch of the GitHub Open-Source Accelerator. With not much information available, we went ahead and applied, hoping for the best. The timing couldn't have been more perfect, as both Matti and I had just wrapped up our freelance gigs to start working full-time on Formbricks.
|
||||
|
||||
As Christmas, New Year's Eve, and my birthday passed, we continued working diligently on Formbricks, iterating to pinpoint the right niche offering. Over the preceding months, we had learned what wouldn't constitute a good venture case ([Typeform open-source](https://formbricks.com/blog/open-source-qualtrics-beats-typeform)), what wasn't technically feasible (building blocks for all form and survey solutions), and what was too narrow to start with ([PMF survey only](https://www.producthunt.com/products/product-market-fit-survey-by-formbricks)).
|
||||
|
||||
January and February came and went. On the 22nd of March, we received an email from the GitHub team:
|
||||
|
||||
<Image
|
||||
src={Mail}
|
||||
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Needless to say, we were thrilled! We were selected from over 1000 open-source projects, alongside renowned and popular projects like [Nuxt](https://github.com/nuxt/nuxt), [TRPC](https://github.com/trpc/trpc), and [Responsively App](https://github.com/responsively-org/responsively-app). Here is a summary of what we got:
|
||||
|
||||
### What we got on paper
|
||||
|
||||
✅ Ten sessions with **well-known** figures from the open-source community (Wednesdays)
|
||||
|
||||
✅ Ten optional co-working sessions (Fridays)
|
||||
|
||||
✅ 20.000 USD equally divided among core maintainers
|
||||
|
||||
✅ One-on-one session with the GitHub team to align on goals and objectives
|
||||
|
||||
### What we also gained
|
||||
|
||||
👌 Network of builders, maintainers, and founders in the open-source space
|
||||
|
||||
👌 Solid connection with GitHub (including a warm introduction to GitHub's venture arm 😏)
|
||||
|
||||
👌 Enhanced credibility in the open-source community, thanks to association with such a significant supporter of open source
|
||||
|
||||
I mean look at all these happy people:
|
||||
|
||||
<Image
|
||||
src={Teams}
|
||||
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
Here's an overview of the ten sessions and their relevance to us as a venture-focused startup:
|
||||
|
||||
**Week 1: Kick-Off, Licensing 101 and setting up with [Abby](https://twitter.com/abbycabs)**
|
||||
|
||||
Great to meet everyone, Abby is a great host and the licensing session was very useful. We had already decided on our license but it was useful nontheless.
|
||||
|
||||
**Week 2: Finding Sponsors with [Caleb Porzio](https://twitter.com/calebporzio)**
|
||||
|
||||
This was a really fun one! Caleb is a driven entrepreneur with many ideas and loooots of experience monetizing his two main projects [Livewire](https://laravel-livewire.com/) and [Alpine.js](https://alpinejs.dev/). Come up with a way to monetize a popular OS project, Caleb scaled it. Not suuuper relevant for us though.
|
||||
|
||||
**Week 3: Taking Funding: [Brian Douglas](https://twitter.com/bdougieYO)**
|
||||
|
||||
Brian is building [OpenSauced](https://opensauced.pizza/) and shared his journey of raising VC as an OS startup. Lots of great insights, Brian is super approachable 😊
|
||||
|
||||
**Week 4: [Evan You](https://evanyou.me/): Sustainable Open Source**
|
||||
|
||||
Evan You famously created Vue.js (which is on track to pass React in GitHub ⭐) and Vite. Evan had a lot of useful Do’s and Dont’s for us, great session!
|
||||
|
||||
**Week 5:** Didn't happen due to Maintainer Summit.
|
||||
|
||||
**Week 6: [Mike Perham](https://github.com/mperham) - Starting a Software Business**
|
||||
|
||||
Mike is an absolute legend! With [SideKiq](https://sidekiq.org/) he makes over 300k USD per month 🤯 He was very open and down to earth. One of his best advice: If a customer annoys you, stop serving them. He was able to pull this off because he has been blogging about Ruby for years and is well-known in the community. And, obviously, his solution kicks ass!
|
||||
|
||||
**Week 7: Duane O’Brien and Dawn Foster: Working with Enterprises**
|
||||
|
||||
Lots of useful insights around how enterprises handle open-source, barriers for corporate use and how to handle corporate sponsorships and donations. The notes will come in really handy down the line!
|
||||
|
||||
**Week 8: [Marko Saric](https://twitter.com/markosaric): SaaS-side of Open Source**
|
||||
|
||||
Marko is the marketing co-founder of [Plausible](https://plausible.io/). I think everyone in the SaaS space knows Plausible since they hit 1M ARR bootstrapped. Marko also blogged a lot about Plausible which really helped it grow in the first years. Gifted marketeer, great session!
|
||||
|
||||
**Week 9: Governance with [Shauna Gordon-McKeon](https://github.com/shaunagm/)**
|
||||
|
||||
For us this wasn’t super relevant as Formbricks is ruled by a BDFL (Benevolent Dictator For Life) i.e. us but the discussion among the teams was really insightful. Helped us a great deal to understand the challenges of purely community-driven projects.
|
||||
|
||||
**Week 10: VC Funding and the legal Side of OSS with [Erica Brescia](https://twitter.com/ericabrescia) from Redpoint**
|
||||
|
||||
Ericas talk was really impressive and so is she: Founder of Bitnami, COO of GitHub and Board Member of the Linux Foundation all happened before she started as an investor at Redpoint Ventures. Her deep insights from both the founder and the VC perspective are invaluable!
|
||||
|
||||
**GitHub Demo Day:** All teams presented what they achieved during the 10 week programm. It was great fun to present Formbricks, [you can watch it on Youtube.](https://www.youtube.com/live/Gj6Bez2182k?feature=share&t=1448)
|
||||
|
||||
## Would we do it again? And should you?
|
||||
|
||||
Yes, absolutely. The sessions were excellent, we met a handful of inspiring builders, and the 20k USD was a helpful financial boost. The application process might evolve, but since all of your code is open-source anyway, you might as well throw your hat in the ring.
|
||||
|
||||
**Gratitude to Kara, Abby, and the entire GitHub team - we learned a lot! 😊**
|
||||
|
||||
<Image
|
||||
src={TitleImage}
|
||||
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
||||
<NewsletterSignup />
|
||||
|
||||
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
1
apps/web/.env
Symbolic link
1
apps/web/.env
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { formbricksEnabled } from "@/lib/formbricks";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/* if (typeof window !== "undefined" && formbricksEnabled) {
|
||||
formbricks.init({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
logLevel: "debug",
|
||||
});
|
||||
} */
|
||||
@@ -16,8 +17,8 @@ export default function FormbricksClient({ session }) {
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && session.user && formbricks) {
|
||||
formbricks.init({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
});
|
||||
formbricks.setUserId(session.user.id);
|
||||
formbricks.setEmail(session.user.email);
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
||||
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
||||
|
||||
// Check that PostHog is client-side (used to handle Next.js SSR)
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
posthogEnabled &&
|
||||
typeof process.env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
|
||||
typeof process.env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
|
||||
typeof env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
|
||||
typeof env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
|
||||
) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY, {
|
||||
api_host: env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
// Disable in development
|
||||
loaded: (posthog) => {
|
||||
if (process.env.NODE_ENV === "development") posthog.opt_out_capturing();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { verifyPassword } from "@/lib/auth";
|
||||
import { verifyToken } from "@/lib/jwt";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -120,12 +121,12 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
}),
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_ID || "",
|
||||
clientSecret: process.env.GITHUB_SECRET || "",
|
||||
clientId: env.GITHUB_ID || "",
|
||||
clientSecret: env.GITHUB_SECRET || "",
|
||||
}),
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
clientId: env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
],
|
||||
@@ -189,7 +190,7 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
async signIn({ user, account }: any) {
|
||||
if (account.provider === "credentials" || account.provider === "token") {
|
||||
if (!user.emailVerified && process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
|
||||
if (!user.emailVerified && env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
|
||||
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
|
||||
}
|
||||
return true;
|
||||
|
||||
228
apps/web/app/api/cron/weekly_summary/email.ts
Normal file
228
apps/web/app/api/cron/weekly_summary/email.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { withEmailTemplate } from "@/lib/email-template";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { Insights, NotificationResponse, Survey, SurveyResponse } from "./types";
|
||||
|
||||
const getEmailSubject = (productName: string) => {
|
||||
return `${productName} User Insights - Last Week by Formbricks`;
|
||||
};
|
||||
|
||||
const notificationHeader = (
|
||||
productName: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) =>
|
||||
`
|
||||
<div style="display: block; padding: 1rem;">
|
||||
<div style="float: left;">
|
||||
<h1>Hey 👋</h1>
|
||||
</div>
|
||||
<div style="float: right;">
|
||||
<p style="text-align: right; margin: 0; font-weight: 600;">Weekly Report for ${productName}</p>
|
||||
${getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
`;
|
||||
|
||||
const getNotificationHeaderimePeriod = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) => {
|
||||
if (startYear == endYear) {
|
||||
return `<p style="text-align: right; margin: 0;">${startDate} - ${endDate} ${endYear}</p>`;
|
||||
} else {
|
||||
return `<p style="text-align: right; margin: 0;">${startDate} ${startYear} - ${endDate} ${endYear}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
const notificationInsight = (insights: Insights) =>
|
||||
`<div style="display: block;">
|
||||
<table style="background-color: #f1f5f9; border-radius:1em; margin-top:1em; margin-bottom:1em;">
|
||||
<tr>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Surveys</p>
|
||||
<h1>${insights.numLiveSurvey}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Displays</p>
|
||||
<h1>${insights.totalDisplays}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Responses</p>
|
||||
<h1>${insights.totalResponses}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completed</p>
|
||||
<h1>${insights.totalCompletedResponses}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completion %</p>
|
||||
<h1>${insights.completionRate.toFixed(2)}%</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function convertSurveyStatus(status) {
|
||||
const statusMap = {
|
||||
inProgress: "Live",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
const getButtonLabel = (count) => {
|
||||
if (count === 1) {
|
||||
return "View Response";
|
||||
}
|
||||
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
|
||||
if (!surveys.length) return ` `;
|
||||
|
||||
return surveys
|
||||
.filter((survey) => survey.responses.length > 0)
|
||||
.map((survey) => {
|
||||
const displayStatus = convertSurveyStatus(survey.status);
|
||||
const isLive = displayStatus === "Live";
|
||||
|
||||
return `
|
||||
<div style="display: block; margin-top:3em;">
|
||||
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses" style="color:#1e293b;">
|
||||
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
|
||||
</a>
|
||||
<span style="display: inline; margin-left: 10px; background-color: ${
|
||||
isLive ? "#34D399" : "#a7f3d0"
|
||||
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
|
||||
${displayStatus}
|
||||
</span>
|
||||
${createSurveyFields(survey.responses)}
|
||||
${
|
||||
survey.responsesCount >= 1
|
||||
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses" style="background: #1e293b; margin-top:1em; font-size:0.9em; font-weight:500">
|
||||
${getButtonLabel(survey.responsesCount)}
|
||||
</a>`
|
||||
: ""
|
||||
}
|
||||
<br/></div><br/>`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const createSurveyFields = (surveryResponses: SurveyResponse[]) => {
|
||||
let surveyFields = "";
|
||||
const responseCount = surveryResponses.length;
|
||||
|
||||
surveryResponses.forEach((response, index) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [headline, answer] of Object.entries(response)) {
|
||||
surveyFields += `
|
||||
<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${headline}</p>
|
||||
<p style="font-weight: 500; margin:0px;">${answer}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
|
||||
if (responseCount >= 2 && index < responseCount - 1) {
|
||||
surveyFields += "<hr/>";
|
||||
}
|
||||
});
|
||||
|
||||
return surveyFields;
|
||||
};
|
||||
|
||||
const notificationFooter = () => {
|
||||
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: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>
|
||||
`;
|
||||
};
|
||||
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
|
||||
return `
|
||||
<p>We’d 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;">Don’t let a week pass without learning about your users:</p>
|
||||
|
||||
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys" style="background: #1e293b; font-size:0.9em; font-weight:500">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>
|
||||
`;
|
||||
};
|
||||
|
||||
export const sendWeeklySummaryNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: NotificationResponse
|
||||
) => {
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${notificationInsight(notificationData.insights)}
|
||||
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
|
||||
${notificationFooter()}
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendNoLiveSurveyNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: NotificationResponse
|
||||
) => {
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
187
apps/web/app/api/cron/weekly_summary/route.ts
Normal file
187
apps/web/app/api/cron/weekly_summary/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email";
|
||||
import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types";
|
||||
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
// check authentication with x-api-key header and CRON_SECRET env variable
|
||||
if (headers().get("x-api-key") !== CRON_SECRET) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// list of email sending promises to wait for
|
||||
const emailSendingPromises: Promise<void>[] = [];
|
||||
|
||||
const products = await getProducts();
|
||||
|
||||
// iterate through the products and send weekly summary email to each team member
|
||||
for await (const product of products) {
|
||||
// check if there are team members that have weekly summary notification enabled
|
||||
const teamMembers = product.team.memberships;
|
||||
const teamMembersWithNotificationEnabled = teamMembers.filter((member) => {
|
||||
return (
|
||||
member.user.notificationSettings?.weeklySummary &&
|
||||
member.user.notificationSettings.weeklySummary[product.id]
|
||||
);
|
||||
});
|
||||
// if there are no team members with weekly summary notification enabled, skip to the next product (do not send email)
|
||||
if (teamMembersWithNotificationEnabled.length == 0) {
|
||||
continue;
|
||||
}
|
||||
// calculate insights for the product
|
||||
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
|
||||
|
||||
// if there were no responses in the last 7 days, send a different email
|
||||
if (notificationResponse.insights.totalCompletedResponses == 0) {
|
||||
for (const teamMember of teamMembersWithNotificationEnabled) {
|
||||
emailSendingPromises.push(
|
||||
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// send weekly summary email
|
||||
for (const teamMember of teamMembersWithNotificationEnabled) {
|
||||
emailSendingPromises.push(
|
||||
sendWeeklySummaryNotificationEmail(teamMember.user.email, notificationResponse)
|
||||
);
|
||||
}
|
||||
}
|
||||
// wait for all emails to be sent
|
||||
await Promise.all(emailSendingPromises);
|
||||
return responses.successResponse({}, true);
|
||||
}
|
||||
|
||||
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
|
||||
const insights = {
|
||||
totalCompletedResponses: 0,
|
||||
totalDisplays: 0,
|
||||
totalResponses: 0,
|
||||
completionRate: 0,
|
||||
numLiveSurvey: 0,
|
||||
};
|
||||
|
||||
const surveys: Survey[] = [];
|
||||
|
||||
// iterate through the surveys and calculate the overall insights
|
||||
for (const survey of environment.surveys) {
|
||||
const surveyData: Survey = {
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
status: survey.status,
|
||||
responsesCount: survey.responses.length,
|
||||
responses: [],
|
||||
};
|
||||
// iterate through the responses and calculate the survey insights
|
||||
for (const response of survey.responses) {
|
||||
// only take the first 3 responses
|
||||
if (surveyData.responses.length >= 1) {
|
||||
break;
|
||||
}
|
||||
const surveyResponse: SurveyResponse = {};
|
||||
for (const question of survey.questions) {
|
||||
const headline = question.headline;
|
||||
const answer = response.data[question.id]?.toString() || null;
|
||||
if (answer === null || answer === "" || answer?.length === 0) {
|
||||
continue;
|
||||
}
|
||||
surveyResponse[headline] = answer;
|
||||
}
|
||||
surveyData.responses.push(surveyResponse);
|
||||
}
|
||||
surveys.push(surveyData);
|
||||
// calculate the overall insights
|
||||
if (survey.status == "inProgress") {
|
||||
insights.numLiveSurvey += 1;
|
||||
}
|
||||
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
|
||||
insights.totalDisplays += survey.displays.length;
|
||||
insights.totalResponses += survey.responses.length;
|
||||
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
|
||||
}
|
||||
// build the notification response needed for the emails
|
||||
const lastWeekDate = new Date();
|
||||
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
|
||||
return {
|
||||
environmentId: environment.id,
|
||||
currentDate: new Date(),
|
||||
lastWeekDate,
|
||||
productName: productName,
|
||||
surveys,
|
||||
insights,
|
||||
};
|
||||
};
|
||||
|
||||
const getProducts = async (): Promise<ProductData[]> => {
|
||||
// gets all products together with team members, surveys, responses, and displays for the last 7 days
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
return await prisma.product.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: sevenDaysAgo,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
81
apps/web/app/api/cron/weekly_summary/types.ts
Normal file
81
apps/web/app/api/cron/weekly_summary/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { TResponseData } from "@formbricks/types/v1/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
|
||||
import { DisplayStatus, SurveyStatus } from "@prisma/client";
|
||||
|
||||
export interface Insights {
|
||||
totalCompletedResponses: number;
|
||||
totalDisplays: number;
|
||||
totalResponses: number;
|
||||
completionRate: number;
|
||||
numLiveSurvey: number;
|
||||
}
|
||||
|
||||
export interface SurveyResponse {
|
||||
[headline: string]: string | number | boolean | Date | string[];
|
||||
}
|
||||
|
||||
export interface Survey {
|
||||
id: string;
|
||||
name: string;
|
||||
responses: SurveyResponse[];
|
||||
responsesCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface NotificationResponse {
|
||||
environmentId: string;
|
||||
currentDate: Date;
|
||||
lastWeekDate: Date;
|
||||
productName: string;
|
||||
surveys: Survey[];
|
||||
insights: Insights;
|
||||
}
|
||||
|
||||
// Prisma Types
|
||||
|
||||
type ResponseData = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
finished: boolean;
|
||||
data: TResponseData;
|
||||
};
|
||||
|
||||
type DisplayData = {
|
||||
status: DisplayStatus;
|
||||
};
|
||||
|
||||
type SurveyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
questions: TSurveyQuestion[];
|
||||
status: SurveyStatus;
|
||||
responses: ResponseData[];
|
||||
displays: DisplayData[];
|
||||
};
|
||||
|
||||
export type EnvironmentData = {
|
||||
id: string;
|
||||
surveys: SurveyData[];
|
||||
};
|
||||
|
||||
type UserData = {
|
||||
email: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
};
|
||||
|
||||
type MembershipData = {
|
||||
user: UserData;
|
||||
};
|
||||
|
||||
type TeamData = {
|
||||
memberships: MembershipData[];
|
||||
};
|
||||
|
||||
export type ProductData = {
|
||||
id: string;
|
||||
name: string;
|
||||
environments: EnvironmentData[];
|
||||
team: TeamData;
|
||||
};
|
||||
@@ -1,10 +1,5 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { markDisplayResponded } from "@formbricks/lib/services/displays";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
@@ -14,21 +9,21 @@ export async function OPTIONS(): Promise<NextResponse> {
|
||||
export async function POST(_: Request, { params }: { params: { displayId: string } }): Promise<NextResponse> {
|
||||
const { displayId } = params;
|
||||
|
||||
const display = await prisma.display.update({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
data: {
|
||||
status: "responded",
|
||||
},
|
||||
});
|
||||
if (!displayId) {
|
||||
return responses.badRequestResponse("Missing displayId");
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
...display,
|
||||
createdAt: display.createdAt.toISOString(),
|
||||
updatedAt: display.updatedAt.toISOString(),
|
||||
},
|
||||
true
|
||||
);
|
||||
try {
|
||||
const display = await markDisplayResponded(displayId);
|
||||
return responses.successResponse(
|
||||
{
|
||||
...display,
|
||||
createdAt: display.createdAt.toISOString(),
|
||||
updatedAt: display.updatedAt.toISOString(),
|
||||
},
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/*
|
||||
THIS FILE IS WORK IN PROGRESS
|
||||
PLEASE DO NOT USE IT YET
|
||||
*/
|
||||
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { InvalidInputError } from "@formbricks/errors";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { createDisplay } from "@formbricks/lib/services/displays";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
|
||||
import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function OPTIONS(): Promise<NextResponse> {
|
||||
@@ -13,76 +13,50 @@ export async function OPTIONS(): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const { surveyId, personId, environmentId } = await request.json();
|
||||
const jsonInput: unknown = await request.json();
|
||||
const inputValidation = ZDisplayInput.safeParse(jsonInput);
|
||||
|
||||
if (!surveyId) {
|
||||
return responses.missingFieldResponse("surveyId", true);
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.missingFieldResponse("environmentId", true);
|
||||
const displayInput = inputValidation.data;
|
||||
|
||||
// find environmentId from surveyId
|
||||
let survey;
|
||||
|
||||
try {
|
||||
survey = await getSurvey(displayInput.surveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// get teamId from environment
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// find teamId & teamOwnerId from environmentId
|
||||
const teamDetails = await getTeamDetails(survey.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
return responses.notFoundResponse("Environment", environmentId, true);
|
||||
// create display
|
||||
let display: TDisplay;
|
||||
try {
|
||||
display = await createDisplay(displayInput);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const teamId = environment.product.team.id;
|
||||
// find team owner
|
||||
const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId;
|
||||
|
||||
const createBody: any = {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
data: {
|
||||
status: "seen",
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (personId) {
|
||||
createBody.data.person = {
|
||||
connect: {
|
||||
id: personId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// create new display
|
||||
const display = await prisma.display.create(createBody);
|
||||
|
||||
if (teamOwnerId) {
|
||||
await capturePosthogEvent(teamOwnerId, "display created", teamId, {
|
||||
surveyId,
|
||||
if (teamDetails?.teamOwnerId) {
|
||||
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId, {
|
||||
surveyId: displayInput.surveyId,
|
||||
});
|
||||
} else {
|
||||
console.warn("Posthog capture not possible. No team owner found");
|
||||
|
||||
@@ -16,6 +16,11 @@ export async function PUT(
|
||||
{ params }: { params: { responseId: string } }
|
||||
): Promise<NextResponse> {
|
||||
const { responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return responses.badRequestResponse("Response ID is missing", undefined, true);
|
||||
}
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { sendToPipeline } from "@/lib/pipelines";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError } from "@formbricks/errors";
|
||||
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
|
||||
import { createResponse } from "@formbricks/lib/services/response";
|
||||
import { getSurvey } from "@formbricks/lib/services/survey";
|
||||
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { NextResponse } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
@@ -40,41 +40,9 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
// prisma call to get the teamId
|
||||
// TODO use services
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id: survey.environmentId },
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
memberships: {
|
||||
where: { role: "owner" },
|
||||
select: { userId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return responses.internalServerErrorResponse("Environment not found");
|
||||
}
|
||||
const {
|
||||
product: {
|
||||
team: { id: teamId, memberships },
|
||||
},
|
||||
} = environment;
|
||||
|
||||
const teamOwnerId = memberships[0]?.userId;
|
||||
|
||||
let response;
|
||||
const teamDetails = await getTeamDetails(survey.environmentId);
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta = {
|
||||
userAgent: {
|
||||
@@ -113,8 +81,9 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
captureTelemetry("response created");
|
||||
if (teamOwnerId) {
|
||||
await capturePosthogEvent(teamOwnerId, "response created", teamId, {
|
||||
|
||||
if (teamDetails?.teamOwnerId) {
|
||||
await capturePosthogEvent(teamDetails.teamOwnerId, "response created", teamDetails.teamId, {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,21 @@ export async function GET() {
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
environment: true,
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
widgetSetupCompleted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!apiKeyData) {
|
||||
|
||||
@@ -3,14 +3,11 @@ import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { populateEnvironment } from "@/lib/populate";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let { inviteToken, ...user } = await request.json();
|
||||
if (
|
||||
inviteToken
|
||||
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
|
||||
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
|
||||
) {
|
||||
if (inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1") {
|
||||
return NextResponse.json({ error: "Signup disabled" }, { status: 403 });
|
||||
}
|
||||
user = { ...user, ...{ email: user.email.toLowerCase() } };
|
||||
@@ -99,7 +96,7 @@ export async function POST(request: Request) {
|
||||
await prisma.invite.delete({ where: { id: inviteId } });
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
|
||||
if (env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
|
||||
await sendVerificationEmail(userData);
|
||||
}
|
||||
return NextResponse.json(userData);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { SignupForm } from "@/components/auth/SignupForm";
|
||||
import FormWrapper from "@/components/auth/FormWrapper";
|
||||
import Testimonial from "@/components/auth/Testimonial";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -18,9 +19,7 @@ export default function SignUpPage() {
|
||||
<div className="col-span-3 flex flex-col items-center justify-center">
|
||||
<FormWrapper>
|
||||
{(
|
||||
inviteToken
|
||||
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
|
||||
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
|
||||
inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
|
||||
) ? (
|
||||
<>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { env } from "@/env.mjs";
|
||||
import type { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
||||
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
||||
|
||||
export default function PosthogIdentify({ session }: { session: Session }) {
|
||||
const posthog = usePostHog();
|
||||
|
||||
@@ -8,6 +8,7 @@ export default function SettingsCard({
|
||||
soon = false,
|
||||
noPadding = false,
|
||||
dangerZone,
|
||||
beta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -15,18 +16,19 @@ export default function SettingsCard({
|
||||
soon?: boolean;
|
||||
noPadding?: boolean;
|
||||
dangerZone?: boolean;
|
||||
beta?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="my-4 w-full bg-white shadow sm:rounded-lg">
|
||||
<div className="rounded-t-lg border-b border-slate-200 bg-slate-100 px-6 py-5">
|
||||
<div className="flex">
|
||||
<h3
|
||||
className={`${
|
||||
dangerZone ? "text-red-600" : "text-slate-900"
|
||||
} "mr-2 text-lg font-medium leading-6 `}>
|
||||
<h3 className={`${dangerZone ? "text-red-600" : "text-slate-900"} "text-lg font-medium leading-6 `}>
|
||||
{title}
|
||||
</h3>
|
||||
{soon && <Badge text="coming soon" size="normal" type="success" />}
|
||||
<div className="ml-2">
|
||||
{beta && <Badge text="Beta" size="normal" type="warning" />}
|
||||
{soon && <Badge text="coming soon" size="normal" type="success" />}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { env } from "@/env.mjs";
|
||||
import {
|
||||
addMember,
|
||||
deleteInvite,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
updateMemberRole,
|
||||
useMembers,
|
||||
} from "@/lib/members";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -28,15 +33,11 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@formbricks/ui";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import AddMemberModal from "./AddMemberModal";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
|
||||
|
||||
type EditMembershipsProps = {
|
||||
environmentId: string;
|
||||
@@ -191,7 +192,7 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
}}>
|
||||
Create New Team
|
||||
</Button>
|
||||
{process.env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
|
||||
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Switch } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { updateNotificationSettings } from "./actions";
|
||||
|
||||
interface AlertSwitchProps {
|
||||
surveyId: string;
|
||||
userId: string;
|
||||
notificationSettings: any;
|
||||
}
|
||||
|
||||
export function AlertSwitch({ surveyId, userId, notificationSettings }: AlertSwitchProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id="every-submission"
|
||||
aria-label="toggle every submission"
|
||||
checked={notificationSettings[surveyId]["responseFinished"]}
|
||||
onCheckedChange={async () => {
|
||||
// update notificiation settings
|
||||
const updatedNotificationSettings = { ...notificationSettings };
|
||||
updatedNotificationSettings[surveyId]["responseFinished"] =
|
||||
!updatedNotificationSettings[surveyId]["responseFinished"];
|
||||
// update db
|
||||
await updateNotificationSettings(userId, notificationSettings);
|
||||
// show success message if toggled on, different message if toggled off
|
||||
if (updatedNotificationSettings[surveyId]["responseFinished"]) {
|
||||
toast.success(`Every new response is coming your way.`);
|
||||
} else {
|
||||
toast.success(`You won't receive notifications anymore.`);
|
||||
}
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,9 @@
|
||||
import { AlertSwitch } from "./AlertSwitch";
|
||||
import { Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import type { NotificationSettings } from "@formbricks/types/users";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
import { Membership, User } from "./types";
|
||||
|
||||
const cleanNotificationSettings = (notificationSettings: NotificationSettings, memberships: Membership[]) => {
|
||||
const newNotificationSettings = {};
|
||||
for (const membership of memberships) {
|
||||
for (const product of membership.team.products) {
|
||||
for (const environment of product.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
// check if the user has notification settings for this survey
|
||||
if (notificationSettings[survey.id]) {
|
||||
newNotificationSettings[survey.id] = notificationSettings[survey.id];
|
||||
} else {
|
||||
newNotificationSettings[survey.id] = {
|
||||
responseFinished: false,
|
||||
weeklySummary: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newNotificationSettings;
|
||||
};
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: User;
|
||||
@@ -34,8 +11,6 @@ interface EditAlertsProps {
|
||||
}
|
||||
|
||||
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
|
||||
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
|
||||
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
@@ -47,9 +22,9 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
|
||||
<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-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1">Product</div>
|
||||
<div className="grid h-12 grid-cols-4 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2">Survey</div>
|
||||
<div className="col-span-1">Product</div>
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -60,48 +35,45 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
|
||||
<TooltipContent>Sends complete responses, no partials.</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="col-span-1 cursor-default text-center">Weekly Summary</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Coming soon 🚀</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="grid-cols-8 space-y-1 p-2">
|
||||
{membership.team.products.map((product) => (
|
||||
<div key={product.id}>
|
||||
{product.environments.map((environment) => (
|
||||
<div key={environment.id}>
|
||||
{environment.surveys.map((survey) => (
|
||||
<div
|
||||
className="grid h-auto w-full cursor-pointer grid-cols-5 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||
key={survey.name}>
|
||||
<div className="col-span-1 flex flex-col justify-center break-all">
|
||||
{product?.name}
|
||||
{membership.team.products.some((product) =>
|
||||
product.environments.some((environment) => environment.surveys.length > 0)
|
||||
) ? (
|
||||
<div className="grid-cols-8 space-y-1 p-2">
|
||||
{membership.team.products.map((product) => (
|
||||
<div key={product.id}>
|
||||
{product.environments.map((environment) => (
|
||||
<div key={environment.id}>
|
||||
{environment.surveys.map((survey) => (
|
||||
<div
|
||||
className="grid h-auto w-full cursor-pointer grid-cols-4 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||
key={survey.name}>
|
||||
<div className=" col-span-2 flex items-center ">
|
||||
<p className="text-slate-800">{survey.name}</p>
|
||||
</div>
|
||||
<div className="col-span-1 flex flex-col justify-center break-all">
|
||||
{product?.name}
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProductId={survey.id}
|
||||
userId={user.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
notificationType={"alert"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" col-span-2 flex items-center ">
|
||||
<p className="text-slate-800">{survey.name}</p>
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<AlertSwitch
|
||||
surveyId={survey.id}
|
||||
userId={user.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<Switch disabled id="weekly-summary" aria-label="toggle weekly summary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
|
||||
<p>No surveys found.</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
Want to loop in team mates?{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
import { Membership, User } from "./types";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: User;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export default function EditWeeklySummary({ memberships, user, environmentId }: 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>
|
||||
<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-2 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div>Product</div>
|
||||
<div className="cursor-default pr-12 text-right">Weekly Summary</div>
|
||||
</div>
|
||||
<div className="grid-cols-8 space-y-1 p-2">
|
||||
{membership.team.products.map((product) => (
|
||||
<div
|
||||
className="grid h-auto w-full cursor-pointer grid-cols-2 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||
key={product.id}>
|
||||
<div>{product?.name}</div>
|
||||
<div className="mr-20 flex justify-end">
|
||||
<NotificationSwitch
|
||||
surveyOrProductId={product.id}
|
||||
userId={user.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
notificationType={"weeklySummary"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
Want to loop in team mates?{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
|
||||
Invite them.
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Switch } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { updateNotificationSettings } from "./actions";
|
||||
import { NotificationSettings } from "@formbricks/types/users";
|
||||
import { useState } from "react";
|
||||
|
||||
interface NotificationSwitchProps {
|
||||
surveyOrProductId: string;
|
||||
userId: string;
|
||||
notificationSettings: NotificationSettings;
|
||||
notificationType: "alert" | "weeklySummary";
|
||||
}
|
||||
|
||||
export function NotificationSwitch({
|
||||
surveyOrProductId,
|
||||
userId,
|
||||
notificationSettings,
|
||||
notificationType,
|
||||
}: NotificationSwitchProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id="notification-switch"
|
||||
aria-label="toggle notification settings"
|
||||
checked={notificationSettings[notificationType][surveyOrProductId]}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
setIsLoading(true);
|
||||
// update notificiation settings
|
||||
const updatedNotificationSettings = { ...notificationSettings };
|
||||
updatedNotificationSettings[notificationType][surveyOrProductId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProductId];
|
||||
await updateNotificationSettings(userId, notificationSettings);
|
||||
setIsLoading(false);
|
||||
toast.success(`Notification settings updated`, { id: "notification-switch" });
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import SettingsCard from "@/app/environments/[environmentId]/settings/SettingsCard";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { NotificationSettings } from "@formbricks/types/users";
|
||||
import { getServerSession } from "next-auth";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import EditAlerts from "./EditAlerts";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import EditWeeklySummary from "./EditWeeklySummary";
|
||||
import type { Membership, User } from "./types";
|
||||
|
||||
async function getUser(userId: string | undefined): Promise<User> {
|
||||
@@ -29,6 +31,27 @@ async function getUser(userId: string | undefined): Promise<User> {
|
||||
return user;
|
||||
}
|
||||
|
||||
function cleanNotificationSettings(notificationSettings: NotificationSettings, memberships: Membership[]) {
|
||||
const newNotificationSettings = { alert: {}, weeklySummary: {} };
|
||||
for (const membership of memberships) {
|
||||
for (const product of membership.team.products) {
|
||||
// set default values for weekly summary
|
||||
newNotificationSettings.weeklySummary[product.id] =
|
||||
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[product.id]) || false;
|
||||
// set default values for alerts
|
||||
for (const environment of product.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
newNotificationSettings.alert[survey.id] =
|
||||
notificationSettings[survey.id]?.responseFinished ||
|
||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||
false; // check for legacy notification settings w/o "alerts" key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newNotificationSettings;
|
||||
}
|
||||
|
||||
async function getMemberships(userId: string): Promise<Membership[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
@@ -72,13 +95,22 @@ export default async function ProfileSettingsPage({ params }) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Notifications" />
|
||||
<SettingsCard title="Email alerts" description="Set up an alert to get an email on new responses.">
|
||||
<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} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
beta
|
||||
title="Weekly summary (Products)"
|
||||
description="Stay up-to-date with a Weekly every Monday.">
|
||||
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
|
||||
toast.success("Question ID updated.");
|
||||
};
|
||||
|
||||
const isInputInvalid = currentValue.trim() === "" || currentValue.includes(" ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="questionId">Question ID</Label>
|
||||
@@ -30,13 +32,14 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
|
||||
value={currentValue}
|
||||
onChange={(e) => setCurrentValue(e.target.value)}
|
||||
disabled={localSurvey.status !== "draft"}
|
||||
className={isInputInvalid ? "focus:border-red-300 border-red-300" : ""}
|
||||
/>
|
||||
{localSurvey.status === "draft" && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="ml-2 bg-slate-600 text-white hover:bg-slate-700 disabled:bg-slate-400"
|
||||
onClick={saveAction}
|
||||
disabled={currentValue === question.id}>
|
||||
disabled={isInputInvalid || currentValue === question.id}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,9 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
|
||||
|
||||
export default async function ResponsesPage({ params }) {
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
console.log(environmentId);
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Headline from "@/components/preview/Headline";
|
||||
import Subheader from "@/components/preview/Subheader";
|
||||
import { env } from "@/env.mjs";
|
||||
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { useProfileMutation } from "@/lib/profile/mutateProfile";
|
||||
@@ -49,11 +50,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
|
||||
console.error(e);
|
||||
toast.error("An error occured saving your settings");
|
||||
}
|
||||
if (
|
||||
formbricksEnabled &&
|
||||
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID &&
|
||||
formbricksResponseId
|
||||
) {
|
||||
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) {
|
||||
const res = await updateResponse(
|
||||
formbricksResponseId,
|
||||
{
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { cn } from "@/../../packages/lib/cn";
|
||||
import Headline from "@/components/preview/Headline";
|
||||
import Subheader from "@/components/preview/Subheader";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { useProfileMutation } from "@/lib/profile/mutateProfile";
|
||||
import { SurveyId } from "@formbricks/js";
|
||||
import { ResponseId, SurveyId } from "@formbricks/js";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { ResponseId } from "@formbricks/js";
|
||||
|
||||
type RoleProps = {
|
||||
next: () => void;
|
||||
@@ -48,13 +48,10 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
|
||||
toast.error("An error occured saving your settings");
|
||||
console.error(e);
|
||||
}
|
||||
if (formbricksEnabled && process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
|
||||
const res = await createResponse(
|
||||
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId,
|
||||
{
|
||||
role: selectedRole.label,
|
||||
}
|
||||
);
|
||||
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
|
||||
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId, {
|
||||
role: selectedRole.label,
|
||||
});
|
||||
if (res.ok) {
|
||||
const response = res.data;
|
||||
setFormbricksResponseId(response.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { GoogleButton } from "@/components/auth/GoogleButton";
|
||||
import { env } from "@/env.mjs";
|
||||
import { Button, PasswordInput } from "@formbricks/ui";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { signIn } from "next-auth/react";
|
||||
@@ -75,7 +76,7 @@ export const SigninForm = () => {
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
|
||||
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
|
||||
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
@@ -105,18 +106,18 @@ export const SigninForm = () => {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
|
||||
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
|
||||
<>
|
||||
<GoogleButton />
|
||||
</>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
|
||||
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
|
||||
<>
|
||||
<GithubButton />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
|
||||
{env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
|
||||
<div className="mt-9 text-center text-xs ">
|
||||
<span className="leading-5 text-slate-500">New to Formbricks?</span>
|
||||
<br />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { PasswordInput } from "@formbricks/ui";
|
||||
import { GoogleButton } from "@/components/auth/GoogleButton";
|
||||
import IsPasswordValid from "@/components/auth/IsPasswordValid";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createUser } from "@/lib/users/users";
|
||||
import { Button, PasswordInput } from "@formbricks/ui";
|
||||
import { XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { GithubButton } from "./GithubButton";
|
||||
import { GoogleButton } from "@/components/auth/GoogleButton";
|
||||
import IsPasswordValid from "@/components/auth/IsPasswordValid";
|
||||
|
||||
export const SignupForm = () => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -33,7 +33,7 @@ export const SignupForm = () => {
|
||||
searchParams?.get("inviteToken")
|
||||
);
|
||||
const url =
|
||||
process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED === "1"
|
||||
env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED === "1"
|
||||
? `/auth/signup-without-verification-success`
|
||||
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
|
||||
|
||||
@@ -131,7 +131,7 @@ export const SignupForm = () => {
|
||||
className="focus:border-brand focus:ring-brand block w-full rounded-md shadow-sm sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
|
||||
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
|
||||
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
@@ -163,36 +163,36 @@ export const SignupForm = () => {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
|
||||
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
|
||||
<>
|
||||
<GoogleButton />
|
||||
</>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
|
||||
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
|
||||
<>
|
||||
<GithubButton />{" "}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(process.env.NEXT_PUBLIC_TERMS_URL || process.env.NEXT_PUBLIC_PRIVACY_URL) && (
|
||||
{(env.NEXT_PUBLIC_TERMS_URL || env.NEXT_PUBLIC_PRIVACY_URL) && (
|
||||
<div className="mt-3 text-center text-xs text-slate-500">
|
||||
By signing up, you agree to our
|
||||
<br />
|
||||
{process.env.NEXT_PUBLIC_TERMS_URL && (
|
||||
{env.NEXT_PUBLIC_TERMS_URL && (
|
||||
<Link
|
||||
className="font-semibold"
|
||||
href="google.com" /* {process.env.NEXT_PUBLIC_TERMS_URL} */
|
||||
href="google.com" /* {env.NEXT_PUBLIC_TERMS_URL} */
|
||||
rel="noreferrer"
|
||||
target="_blank">
|
||||
Terms of Service
|
||||
</Link>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_TERMS_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
|
||||
{process.env.NEXT_PUBLIC_PRIVACY_URL && (
|
||||
{env.NEXT_PUBLIC_TERMS_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
|
||||
{env.NEXT_PUBLIC_PRIVACY_URL && (
|
||||
<Link
|
||||
className="font-semibold"
|
||||
href="google.com" /* {/* process.env.NEXT_PUBLIC_PRIVACY_URL }*/
|
||||
href="google.com" /* {/* env.NEXT_PUBLIC_PRIVACY_URL }*/
|
||||
rel="noreferrer"
|
||||
target="_blank">
|
||||
Privacy Policy.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Input } from "@/../../packages/ui";
|
||||
import SubmitButton from "@/components/preview/SubmitButton";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Headline from "./Headline";
|
||||
import Subheader from "./Subheader";
|
||||
|
||||
@@ -20,6 +20,13 @@ export default function MultipleChoiceSingleQuestion({
|
||||
brandColor,
|
||||
}: MultipleChoiceSingleProps) {
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
|
||||
const otherSpecify = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChoice === "other") {
|
||||
otherSpecify.current?.focus();
|
||||
}
|
||||
}, [selectedChoice]);
|
||||
/* const [isIphone, setIsIphone] = useState(false);
|
||||
|
||||
|
||||
@@ -31,7 +38,7 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const value = e.currentTarget[question.id].value;
|
||||
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
|
||||
const data = {
|
||||
[question.id]: value,
|
||||
};
|
||||
@@ -72,6 +79,7 @@ export default function MultipleChoiceSingleQuestion({
|
||||
{choice.id === "other" && selectedChoice === "other" && (
|
||||
<Input
|
||||
id={`${choice.id}-label`}
|
||||
ref={otherSpecify}
|
||||
name={question.id}
|
||||
placeholder="Please specify"
|
||||
className="mt-3 bg-white focus:border-slate-300"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LegalFooter() {
|
||||
if (!process.env.NEXT_PUBLIC_IMPRINT_URL && !process.env.NEXT_PUBLIC_PRIVACY_URL) return null;
|
||||
if (!env.NEXT_PUBLIC_IMPRINT_URL && !env.NEXT_PUBLIC_PRIVACY_URL) return null;
|
||||
return (
|
||||
<div className="top-0 z-10 w-full border-b bg-white">
|
||||
<div className="mx-auto max-w-lg p-3 text-center text-sm text-slate-400">
|
||||
{process.env.NEXT_PUBLIC_IMPRINT_URL && (
|
||||
<Link href={process.env.NEXT_PUBLIC_IMPRINT_URL} target="_blank">
|
||||
{env.NEXT_PUBLIC_IMPRINT_URL && (
|
||||
<Link href={env.NEXT_PUBLIC_IMPRINT_URL} target="_blank">
|
||||
Imprint
|
||||
</Link>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_IMPRINT_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && <span> | </span>}
|
||||
{process.env.NEXT_PUBLIC_PRIVACY_URL && (
|
||||
<Link href={process.env.NEXT_PUBLIC_PRIVACY_URL} target="_blank">
|
||||
{env.NEXT_PUBLIC_IMPRINT_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> | </span>}
|
||||
{env.NEXT_PUBLIC_PRIVACY_URL && (
|
||||
<Link href={env.NEXT_PUBLIC_PRIVACY_URL} target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
)}
|
||||
|
||||
110
apps/web/env.mjs
Normal file
110
apps/web/env.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z.string().url(),
|
||||
PRISMA_GENERATE_DATAPROXY: z.enum(["true", ""]).optional(),
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
NEXTAUTH_URL: z.string().url().optional(),
|
||||
MAIL_FROM: z.string().email().optional(),
|
||||
SMTP_HOST: z.string().min(1).optional(),
|
||||
SMTP_PORT: z.string().min(1).optional(),
|
||||
SMTP_USER: z.string().min(1).optional(),
|
||||
SMTP_PASSWORD: z.string().min(1).optional(),
|
||||
SMTP_SECURE_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||
CRON_SECRET: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
|
||||
},
|
||||
/*
|
||||
* Environment variables available on the client (and server).
|
||||
*
|
||||
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_WEBAPP_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_INVITE_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_PRIVACY_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_TERMS_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_IMPRINT_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
|
||||
},
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
* we need to manually destructure them to make sure all are included in bundle.
|
||||
*
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
PRISMA_GENERATE_DATAPROXY: process.env.PRISMA_GENERATE_DATAPROXY,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
|
||||
SMTP_SECURE_ENABLED: process.env.SMTP_SECURE_ENABLED,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
NEXT_PUBLIC_WEBAPP_URL: process.env.NEXT_PUBLIC_WEBAPP_URL,
|
||||
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED,
|
||||
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED,
|
||||
NEXT_PUBLIC_SIGNUP_DISABLED: process.env.NEXT_PUBLIC_SIGNUP_DISABLED,
|
||||
NEXT_PUBLIC_INVITE_DISABLED: process.env.NEXT_PUBLIC_INVITE_DISABLED,
|
||||
NEXT_PUBLIC_PRIVACY_URL: process.env.NEXT_PUBLIC_PRIVACY_URL,
|
||||
NEXT_PUBLIC_TERMS_URL: process.env.NEXT_PUBLIC_TERMS_URL,
|
||||
NEXT_PUBLIC_IMPRINT_URL: process.env.NEXT_PUBLIC_IMPRINT_URL,
|
||||
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED,
|
||||
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED,
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID,
|
||||
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD,
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
@@ -16,20 +17,20 @@ interface sendEmailData {
|
||||
html: string;
|
||||
}
|
||||
|
||||
const sendEmail = async (emailData: sendEmailData) => {
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD,
|
||||
},
|
||||
// logger: true,
|
||||
// debug: true,
|
||||
});
|
||||
const emailDefaults = {
|
||||
from: `Formbricks <${process.env.MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
from: `Formbricks <${env.MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
};
|
||||
@@ -128,7 +129,7 @@ export const sendResponseFinishedEmail = async (
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail || process.env.MAIL_FROM,
|
||||
replyTo: personEmail || env.MAIL_FROM,
|
||||
html: withEmailTemplate(`<h1>Survey completed</h1>Someone just completed your survey "${survey.name}"<br/>
|
||||
|
||||
<hr/>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import formbricks, { PersonId, SurveyId, ResponseId } from "@formbricks/js";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const formbricksEnabled =
|
||||
typeof process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
|
||||
|
||||
export const createResponse = async (
|
||||
surveyId: SurveyId,
|
||||
data: { [questionId: string]: any },
|
||||
finished: boolean = false
|
||||
) => {
|
||||
): Promise<any> => {
|
||||
const api = formbricks.getApi();
|
||||
const personId = formbricks.getPerson()?.id as PersonId;
|
||||
return await api.createResponse({
|
||||
@@ -22,7 +23,7 @@ export const updateResponse = async (
|
||||
responseId: ResponseId,
|
||||
data: { [questionId: string]: any },
|
||||
finished: boolean = false
|
||||
) => {
|
||||
): Promise<any> => {
|
||||
const api = formbricks.getApi();
|
||||
return await api.updateResponse({
|
||||
responseId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export function createToken(userId, userEmail, options = {}) {
|
||||
return jwt.sign({ id: userId }, process.env.NEXTAUTH_SECRET + userEmail, options);
|
||||
return jwt.sign({ id: userId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
}
|
||||
|
||||
export async function verifyToken(token, userEmail = "") {
|
||||
@@ -20,11 +21,11 @@ export async function verifyToken(token, userEmail = "") {
|
||||
userEmail = foundUser.email;
|
||||
}
|
||||
|
||||
return jwt.verify(token, process.env.NEXTAUTH_SECRET + userEmail);
|
||||
return jwt.verify(token, env.NEXTAUTH_SECRET + userEmail);
|
||||
}
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}) => {
|
||||
return jwt.sign({ inviteId, email }, process.env.NEXTAUTH_SECRET, options);
|
||||
return jwt.sign({ inviteId, email }, env.NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyInviteToken = async (token: string) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useLinkSurvey = (surveyId: string) => {
|
||||
|
||||
export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
|
||||
const [prefilling, setPrefilling] = useState(false);
|
||||
const [prefilling, setPrefilling] = useState(true);
|
||||
const [progress, setProgress] = useState(0); // [0, 1]
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [loadingElement, setLoadingElement] = useState(false);
|
||||
@@ -49,13 +49,11 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
if (isPreview) return;
|
||||
|
||||
// create display
|
||||
createDisplay(
|
||||
{ surveyId: survey.id },
|
||||
`${window.location.protocol}//${window.location.host}`,
|
||||
survey.environmentId
|
||||
).then((display) => {
|
||||
setDisplayId(display.id);
|
||||
});
|
||||
createDisplay({ surveyId: survey.id }, `${window.location.protocol}//${window.location.host}`).then(
|
||||
(display) => {
|
||||
setDisplayId(display.id);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [survey, isPreview, isLoadingPerson]);
|
||||
@@ -117,11 +115,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
`${window.location.protocol}//${window.location.host}`
|
||||
);
|
||||
if (displayId) {
|
||||
markDisplayResponded(
|
||||
displayId,
|
||||
`${window.location.protocol}//${window.location.host}`,
|
||||
survey.environmentId
|
||||
);
|
||||
markDisplayResponded(displayId, `${window.location.protocol}//${window.location.host}`);
|
||||
}
|
||||
setResponseId(response.id);
|
||||
} else if (responseId && !isPreview) {
|
||||
@@ -166,7 +160,6 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
if (!currentQuestion) return;
|
||||
const firstQuestionId = survey.questions[0].id;
|
||||
if (currentQuestion.id !== firstQuestionId) return;
|
||||
setPrefilling(true);
|
||||
const question = survey.questions.find((q) => q.id === firstQuestionId);
|
||||
if (!question) throw new Error("Question not found");
|
||||
|
||||
@@ -239,6 +232,11 @@ const checkValidity = (question: Question, answer: any): boolean => {
|
||||
if (answer !== "clicked" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case QuestionType.Consent: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "accepted" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case QuestionType.Rating: {
|
||||
answer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(answer));
|
||||
@@ -257,6 +255,7 @@ const createAnswer = (question: Question, answer: string): string | number | str
|
||||
switch (question.type) {
|
||||
case QuestionType.OpenText:
|
||||
case QuestionType.MultipleChoiceSingle:
|
||||
case QuestionType.Consent:
|
||||
case QuestionType.CTA: {
|
||||
return answer;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import "./env.mjs";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
require("@next/env").loadEnvConfig("../../");
|
||||
|
||||
const { createId } = require("@paralleldrive/cuid2");
|
||||
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
@@ -66,4 +65,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
export default nextConfig;
|
||||
@@ -21,10 +21,10 @@
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@json2csv/node": "^7.0.1",
|
||||
"@next/env": "^13.4.8",
|
||||
"@paralleldrive/cuid2": "^2.2.1",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@t3-oss/env-nextjs": "^0.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"eslint-config-next": "^13.4.8",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getPlan, hasEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
|
||||
@@ -71,7 +72,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1") {
|
||||
if (env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1") {
|
||||
const plan = await getPlan(req, res);
|
||||
if (plan === "free" && responses.length > RESPONSES_LIMIT_FREE) {
|
||||
return res.json({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/lib/api/apiHelper";
|
||||
import { sendInviteMemberEmail } from "@/lib/email";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -20,7 +21,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(403).json({ message: "Not authorized" });
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
|
||||
if (env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
|
||||
return res.status(403).json({ message: "Invite Disabled" });
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
||||
"db:migrate:vercel": "turbo run db:migrate:vercel",
|
||||
"db:push": "turbo run db:push",
|
||||
"dev": "turbo run dev --parallel --filter=!formbricks-com",
|
||||
"dev": "turbo run dev --parallel --filter=web... --filter=demo...",
|
||||
"start": "turbo run start --parallel",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"generate": "turbo run generate",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { DisplayCreateRequest, JsConfig, Response } from "../../../types/js";
|
||||
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
|
||||
import type { JsConfig } from "../../../types/js";
|
||||
import { NetworkError, Result, err, ok, okVoid } from "./errors";
|
||||
|
||||
export const createDisplay = async (
|
||||
displayCreateRequest: DisplayCreateRequest,
|
||||
displayCreateRequest: TDisplayInput,
|
||||
config: JsConfig
|
||||
): Promise<Result<Response, NetworkError>> => {
|
||||
const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays`;
|
||||
): Promise<Result<TDisplay, NetworkError>> => {
|
||||
const url = `${config.apiHost}/api/v1/client/displays`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -14,41 +15,38 @@ export const createDisplay = async (
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const jsonRes = await res.json();
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Could not create display",
|
||||
status: res.status,
|
||||
url,
|
||||
responseMessage: jsonRes.message,
|
||||
responseMessage: await res.text(),
|
||||
});
|
||||
}
|
||||
|
||||
const response = (await res.json()) as Response;
|
||||
const jsonRes = await res.json();
|
||||
|
||||
return ok(response);
|
||||
return ok(jsonRes.data as TDisplay);
|
||||
};
|
||||
|
||||
export const markDisplayResponded = async (
|
||||
displayId: string,
|
||||
config: JsConfig
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays/${displayId}/responded`;
|
||||
const url = `${config.apiHost}/api/v1/client/displays/${displayId}/responded`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const jsonRes = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Could not mark display as responded",
|
||||
status: res.status,
|
||||
url,
|
||||
responseMessage: jsonRes.message,
|
||||
responseMessage: await res.text(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { DisplayCreateRequest } from "@formbricks/types/js";
|
||||
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
|
||||
|
||||
export const createDisplay = async (
|
||||
displayCreateRequest: DisplayCreateRequest,
|
||||
apiHost: string,
|
||||
environmentId: string
|
||||
): Promise<{ id: string }> => {
|
||||
const res = await fetch(`${apiHost}/api/v1/client/environments/${environmentId}/displays`, {
|
||||
displayCreateRequest: TDisplayInput,
|
||||
apiHost: string
|
||||
): Promise<TDisplay> => {
|
||||
const res = await fetch(`${apiHost}/api/v1/client/displays`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(displayCreateRequest),
|
||||
@@ -14,21 +13,15 @@ export const createDisplay = async (
|
||||
console.error(res.text);
|
||||
throw new Error("Could not create display");
|
||||
}
|
||||
return await res.json();
|
||||
const resJson = await res.json();
|
||||
return resJson.data;
|
||||
};
|
||||
|
||||
export const markDisplayResponded = async (
|
||||
displayId: string,
|
||||
apiHost: string,
|
||||
environmentId: string
|
||||
): Promise<void> => {
|
||||
const res = await fetch(
|
||||
`${apiHost}/api/v1/client/environments/${environmentId}/displays/${displayId}/responded`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
export const markDisplayResponded = async (displayId: string, apiHost: string): Promise<void> => {
|
||||
const res = await fetch(`${apiHost}/api/v1/client/displays/${displayId}/responded`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not update display");
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@ export const WEBAPP_URL =
|
||||
|
||||
// Other
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
|
||||
export const CRON_SECRET = process.env.CRON_SECRET;
|
||||
|
||||
100
packages/lib/services/displays.ts
Normal file
100
packages/lib/services/displays.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { transformPrismaPerson } from "./person";
|
||||
|
||||
const selectDisplay = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
attributeClass: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
status: true,
|
||||
};
|
||||
|
||||
export const createDisplay = async (displayInput: TDisplayInput): Promise<TDisplay> => {
|
||||
try {
|
||||
const displayPrisma = await prisma.display.create({
|
||||
data: {
|
||||
survey: {
|
||||
connect: {
|
||||
id: displayInput.surveyId,
|
||||
},
|
||||
},
|
||||
status: "seen",
|
||||
|
||||
...(displayInput.personId && {
|
||||
person: {
|
||||
connect: {
|
||||
id: displayInput.personId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
const display: TDisplay = {
|
||||
...displayPrisma,
|
||||
person: transformPrismaPerson(displayPrisma.person),
|
||||
};
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const markDisplayResponded = async (displayId: string): Promise<TDisplay> => {
|
||||
try {
|
||||
if (!displayId) throw new Error("Display ID is required");
|
||||
|
||||
const displayPrisma = await prisma.display.update({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
data: {
|
||||
status: "responded",
|
||||
},
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
if (!displayPrisma) {
|
||||
throw new ResourceNotFoundError("Display", displayId);
|
||||
}
|
||||
|
||||
const display: TDisplay = {
|
||||
...displayPrisma,
|
||||
person: transformPrismaPerson(displayPrisma.person),
|
||||
};
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -37,7 +37,7 @@ export const transformPrismaPerson = (person: TransformPersonInput | null): Tran
|
||||
id: person.id,
|
||||
attributes: attributes,
|
||||
createdAt: person.createdAt,
|
||||
updatedAt: person.updatedAt
|
||||
updatedAt: person.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
50
packages/lib/services/teamDetails.ts
Normal file
50
packages/lib/services/teamDetails.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
|
||||
export const getTeamDetails = async (environmentId: string) => {
|
||||
try {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
select: {
|
||||
product: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("Environment", environmentId);
|
||||
}
|
||||
|
||||
const teamId: string = environment.product.team.id;
|
||||
// find team owner
|
||||
const teamOwnerId: string | undefined = environment.product.team.memberships.find(
|
||||
(m) => m.role === "owner"
|
||||
)?.userId;
|
||||
|
||||
return {
|
||||
teamId: teamId,
|
||||
teamOwnerId: teamOwnerId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -33,3 +33,15 @@ export interface AttributeFilter {
|
||||
condition: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SurveyNotificationData {
|
||||
id: string;
|
||||
numDisplays: number;
|
||||
numDisplaysResponded: number;
|
||||
responseLenght: number;
|
||||
responseCompletedLength: number;
|
||||
latestResponse: any;
|
||||
questions: Question[];
|
||||
status: "draft" | "inProgress" | "archived" | "paused" | "completed";
|
||||
name: String;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface NotificationSettings {
|
||||
[surveyId: string]: {
|
||||
responseFinished: boolean;
|
||||
weeklySummary: boolean;
|
||||
alert: {
|
||||
[surveyId: string]: boolean;
|
||||
};
|
||||
weeklySummary: {
|
||||
[productId: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
20
packages/types/v1/displays.ts
Normal file
20
packages/types/v1/displays.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { ZPerson } from "./people";
|
||||
|
||||
export const ZDisplay = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
surveyId: z.string().cuid2(),
|
||||
person: ZPerson.nullable(),
|
||||
status: z.enum(["seen", "responded"]),
|
||||
});
|
||||
|
||||
export type TDisplay = z.infer<typeof ZDisplay>;
|
||||
|
||||
export const ZDisplayInput = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
personId: z.string().cuid2().optional(),
|
||||
});
|
||||
|
||||
export type TDisplayInput = z.infer<typeof ZDisplayInput>;
|
||||
@@ -7,7 +7,7 @@ export const ZPerson = z.object({
|
||||
id: z.string().cuid2(),
|
||||
attributes: ZPersonAttributes,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type TPerson = z.infer<typeof ZPerson>;
|
||||
|
||||
@@ -204,7 +204,7 @@ export const ZSurvey = z.object({
|
||||
questions: ZSurveyQuestions,
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
delay: z.number(),
|
||||
autoComplete: z.union([z.boolean(), z.null()]),
|
||||
autoComplete: z.union([z.number(), z.null()]),
|
||||
analytics: z.object({
|
||||
numDisplays: z.number(),
|
||||
responseRate: z.number(),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZUserNotificationSettings = z.record(
|
||||
z.object({
|
||||
responseFinished: z.boolean(),
|
||||
weeklySummary: z.boolean(),
|
||||
})
|
||||
);
|
||||
export const ZUserNotificationSettings = z.object({
|
||||
alert: z.record(z.boolean()),
|
||||
weeklySummary: z.record(z.boolean()),
|
||||
});
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -1,5 +1,9 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -162,9 +166,6 @@ importers:
|
||||
'@json2csv/node':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1
|
||||
'@next/env':
|
||||
specifier: ^13.4.8
|
||||
version: 13.4.8
|
||||
'@paralleldrive/cuid2':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@@ -174,6 +175,9 @@ importers:
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5(react-dom@18.2.0)(react@18.2.0)
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0(typescript@5.1.6)(zod@3.21.4)
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
@@ -5149,6 +5153,27 @@ packages:
|
||||
dependencies:
|
||||
defer-to-connect: 1.1.3
|
||||
|
||||
/@t3-oss/env-core@0.6.0(typescript@5.1.6)(zod@3.21.4):
|
||||
resolution: {integrity: sha512-3FkPAba069WRZVVab/sB1m3eSGn/rZeypx5k+sWEu1d+k0OQdRDnvFS+7MtxYgqVrwaRk3b7yVnX2dgSPVmWPQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.7.2'
|
||||
zod: ^3.0.0
|
||||
dependencies:
|
||||
typescript: 5.1.6
|
||||
zod: 3.21.4
|
||||
dev: false
|
||||
|
||||
/@t3-oss/env-nextjs@0.6.0(typescript@5.1.6)(zod@3.21.4):
|
||||
resolution: {integrity: sha512-SpzcGNIbUYcQw4zPPFeRJqCC1560zL7QmB0puIqOnuCsmykPkqHPX+n9CNZLXVQerboHzfvb7Kd+jAdouk72Vw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.7.2'
|
||||
zod: ^3.0.0
|
||||
dependencies:
|
||||
'@t3-oss/env-core': 0.6.0(typescript@5.1.6)(zod@3.21.4)
|
||||
typescript: 5.1.6
|
||||
zod: 3.21.4
|
||||
dev: false
|
||||
|
||||
/@tailwindcss/forms@0.5.3(tailwindcss@3.3.2):
|
||||
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
|
||||
peerDependencies:
|
||||
@@ -20726,7 +20751,3 @@ packages:
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"CRON_SECRET",
|
||||
"GITHUB_ID",
|
||||
"GITHUB_SECRET",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
@@ -55,6 +56,8 @@
|
||||
"RAILWAY_STATIC_URL",
|
||||
"RENDER_EXTERNAL_URL",
|
||||
"SENTRY_DSN",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"TELEMETRY_DISABLED",
|
||||
"VERCEL_URL"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user