From 9e9db7103efb390f5a717328db5300bfd0645f64 Mon Sep 17 00:00:00 2001 From: Bhaskar Singh Date: Thu, 6 Jul 2023 19:17:33 +0530 Subject: [PATCH] Add Weekly Summary Feature (Beta) (#431) * Added Notification API * Added Email functionality to the weekly notification * Added no live survey email notification * Activating weeklySummary notification alertSwitch * Adding check to include only surveys which have weeklySummary enabled * Updated the condition for weekSummary notification check * update UI * Update to reduce number of database calls * Updated the email subject when no survey in weeklysummary * applied pnpm format * update notification settings with new types and fix functionality * loop through all products to send weekly summary email, colocate files * fix build errors * add more types * add vercel.json for cron configuration * remove console.logs, limit responses to 5 per survey * update email subject * improve how responses are displayed in summary email * update email layout * add cron to github action instead of vercel * add github action * add beta flag --------- Co-authored-by: Johannes Co-authored-by: Matthias Nannt --- .env.example | 5 +- .github/workflows/cron-weeklySummary.yml | 23 ++ apps/web/app/api/cron/weekly_summary/email.ts | 228 ++++++++++++++++++ apps/web/app/api/cron/weekly_summary/route.ts | 187 ++++++++++++++ apps/web/app/api/cron/weekly_summary/types.ts | 81 +++++++ .../[environmentId]/settings/SettingsCard.tsx | 12 +- .../settings/notifications/AlertSwitch.tsx | 39 --- .../settings/notifications/EditAlerts.tsx | 110 ++++----- .../notifications/EditWeeklySummary.tsx | 56 +++++ .../notifications/NotificationSwitch.tsx | 45 ++++ .../settings/notifications/page.tsx | 40 ++- .../surveys/[surveyId]/responses/page.tsx | 3 + apps/web/lib/email.ts | 2 +- packages/lib/constants.ts | 1 + packages/types/surveys.ts | 12 + packages/types/users.ts | 8 +- packages/types/v1/users.ts | 10 +- turbo.json | 1 + 18 files changed, 735 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/cron-weeklySummary.yml create mode 100644 apps/web/app/api/cron/weekly_summary/email.ts create mode 100644 apps/web/app/api/cron/weekly_summary/route.ts create mode 100644 apps/web/app/api/cron/weekly_summary/types.ts delete mode 100644 apps/web/app/environments/[environmentId]/settings/notifications/AlertSwitch.tsx create mode 100644 apps/web/app/environments/[environmentId]/settings/notifications/EditWeeklySummary.tsx create mode 100644 apps/web/app/environments/[environmentId]/settings/notifications/NotificationSwitch.tsx diff --git a/.env.example b/.env.example index 3a1f166417..166a783e5b 100644 --- a/.env.example +++ b/.env.example @@ -108,4 +108,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= \ No newline at end of file +NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID= + +# Cron Secret +CRON_SECRET= \ No newline at end of file diff --git a/.github/workflows/cron-weeklySummary.yml b/.github/workflows/cron-weeklySummary.yml new file mode 100644 index 0000000000..b17c5bc113 --- /dev/null +++ b/.github/workflows/cron-weeklySummary.yml @@ -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 diff --git a/apps/web/app/api/cron/weekly_summary/email.ts b/apps/web/app/api/cron/weekly_summary/email.ts new file mode 100644 index 0000000000..5e42a6083a --- /dev/null +++ b/apps/web/app/api/cron/weekly_summary/email.ts @@ -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 +) => + ` +
+
+

Hey πŸ‘‹

+
+
+

Weekly Report for ${productName}

+ ${getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)} +
+
+
+
+ `; + +const getNotificationHeaderimePeriod = ( + startDate: string, + endDate: string, + startYear: number, + endYear: number +) => { + if (startYear == endYear) { + return `

${startDate} - ${endDate} ${endYear}

`; + } else { + return `

${startDate} ${startYear} - ${endDate} ${endYear}

`; + } +}; + +const notificationInsight = (insights: Insights) => + `
+ + + + + + + + +
+

Surveys

+

${insights.numLiveSurvey}

+
+

Displays

+

${insights.totalDisplays}

+
+

Responses

+

${insights.totalResponses}

+
+

Completed

+

${insights.totalCompletedResponses}

+
+

Completion %

+

${insights.completionRate.toFixed(2)}%

+
+
+`; + +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 ` +
+ +

${survey.name}

+
+ + ${displayStatus} + + ${createSurveyFields(survey.responses)} + ${ + survey.responsesCount >= 1 + ? ` + ${getButtonLabel(survey.responsesCount)} + ` + : "" + } +

`; + }) + .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 += ` +
+

${headline}

+

${answer}

+
+ `; + } + + // Add
only when there are 2 or more responses to display, and it's not the last response + if (responseCount >= 2 && index < responseCount - 1) { + surveyFields += "
"; + } + }); + + return surveyFields; +}; + +const notificationFooter = () => { + return ` +

All the best,

+

The Formbricks Team

+

This is a Beta feature. If you experience any issues, please let us know by replying to this email πŸ™

+ `; +}; + +const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => { + return ` +

We’d love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.

+ +

Don’t let a week pass without learning about your users:

+ + Setup a new survey + +
+

Need help finding the right survey for your product? Pick a 15-minute slot in our CEOs calendar or reply to this email :)

+ + +

All the best,

+

The Formbricks Team

+ +

This is a Beta feature. If you experience any issues, please let us know by replying to this email πŸ™

+ `; +}; + +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)} + `), + }); +}; diff --git a/apps/web/app/api/cron/weekly_summary/route.ts b/apps/web/app/api/cron/weekly_summary/route.ts new file mode 100644 index 0000000000..0fce4f448f --- /dev/null +++ b/apps/web/app/api/cron/weekly_summary/route.ts @@ -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 { + // 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[] = []; + + 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 => { + // 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, + }, + }, + }, + }, + }, + }, + }, + }); +}; diff --git a/apps/web/app/api/cron/weekly_summary/types.ts b/apps/web/app/api/cron/weekly_summary/types.ts new file mode 100644 index 0000000000..0b3a6b0995 --- /dev/null +++ b/apps/web/app/api/cron/weekly_summary/types.ts @@ -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; +}; diff --git a/apps/web/app/environments/[environmentId]/settings/SettingsCard.tsx b/apps/web/app/environments/[environmentId]/settings/SettingsCard.tsx index c7c7c4cf4a..a75a184b20 100644 --- a/apps/web/app/environments/[environmentId]/settings/SettingsCard.tsx +++ b/apps/web/app/environments/[environmentId]/settings/SettingsCard.tsx @@ -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 (
-

+

{title}

- {soon && } +
+ {beta && } + {soon && } +

{description}

diff --git a/apps/web/app/environments/[environmentId]/settings/notifications/AlertSwitch.tsx b/apps/web/app/environments/[environmentId]/settings/notifications/AlertSwitch.tsx deleted file mode 100644 index 9e2ed25f1f..0000000000 --- a/apps/web/app/environments/[environmentId]/settings/notifications/AlertSwitch.tsx +++ /dev/null @@ -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 ( - { - // 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(); - }} - /> - ); -} diff --git a/apps/web/app/environments/[environmentId]/settings/notifications/EditAlerts.tsx b/apps/web/app/environments/[environmentId]/settings/notifications/EditAlerts.tsx index 6bfeae6048..16e2035121 100644 --- a/apps/web/app/environments/[environmentId]/settings/notifications/EditAlerts.tsx +++ b/apps/web/app/environments/[environmentId]/settings/notifications/EditAlerts.tsx @@ -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

{membership.team.name}

-
-
Product
+
Survey
+
Product
@@ -60,48 +35,45 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle Sends complete responses, no partials. - - - - -
Weekly Summary
-
- Coming soon πŸš€ -
-
-
- {membership.team.products.map((product) => ( -
- {product.environments.map((environment) => ( -
- {environment.surveys.map((survey) => ( -
-
- {product?.name} + {membership.team.products.some((product) => + product.environments.some((environment) => environment.surveys.length > 0) + ) ? ( +
+ {membership.team.products.map((product) => ( +
+ {product.environments.map((environment) => ( +
+ {environment.surveys.map((survey) => ( +
+
+

{survey.name}

+
+
+ {product?.name} +
+
+ +
-
-

{survey.name}

-
-
- -
-
- -
-
- ))} -
- ))} -
- ))} -
+ ))} +
+ ))} +
+ ))} +
+ ) : ( +
+

No surveys found.

+
+ )}

Want to loop in team mates?{" "} diff --git a/apps/web/app/environments/[environmentId]/settings/notifications/EditWeeklySummary.tsx b/apps/web/app/environments/[environmentId]/settings/notifications/EditWeeklySummary.tsx new file mode 100644 index 0000000000..eb3be2ab06 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/settings/notifications/EditWeeklySummary.tsx @@ -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) => ( + <> +

+
+ +
+

{membership.team.name}

+
+
+
+
Product
+
Weekly Summary
+
+
+ {membership.team.products.map((product) => ( +
+
{product?.name}
+
+ +
+
+ ))} +
+

+ Want to loop in team mates?{" "} + + Invite them. + +

+
+ + ))} + + ); +} diff --git a/apps/web/app/environments/[environmentId]/settings/notifications/NotificationSwitch.tsx b/apps/web/app/environments/[environmentId]/settings/notifications/NotificationSwitch.tsx new file mode 100644 index 0000000000..b33a9f7618 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/settings/notifications/NotificationSwitch.tsx @@ -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 ( + { + 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(); + }} + /> + ); +} diff --git a/apps/web/app/environments/[environmentId]/settings/notifications/page.tsx b/apps/web/app/environments/[environmentId]/settings/notifications/page.tsx index 306a6afed3..9cf2499322 100644 --- a/apps/web/app/environments/[environmentId]/settings/notifications/page.tsx +++ b/apps/web/app/environments/[environmentId]/settings/notifications/page.tsx @@ -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 { @@ -29,6 +31,27 @@ async function getUser(userId: string | undefined): Promise { 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 { 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 (
- + + + +
); } diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx index e02be9caab..a19c77a084 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/page.tsx @@ -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"); diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts index b7598c5079..a08164e7d9 100644 --- a/apps/web/lib/email.ts +++ b/apps/web/lib/email.ts @@ -16,7 +16,7 @@ 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, diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 9d0c39072e..9b2efee6a7 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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; diff --git a/packages/types/surveys.ts b/packages/types/surveys.ts index ef83b7e3ab..c40c30a5a9 100644 --- a/packages/types/surveys.ts +++ b/packages/types/surveys.ts @@ -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; +} diff --git a/packages/types/users.ts b/packages/types/users.ts index 54a0215ead..18463fe328 100644 --- a/packages/types/users.ts +++ b/packages/types/users.ts @@ -1,6 +1,8 @@ export interface NotificationSettings { - [surveyId: string]: { - responseFinished: boolean; - weeklySummary: boolean; + alert: { + [surveyId: string]: boolean; + }; + weeklySummary: { + [productId: string]: boolean; }; } diff --git a/packages/types/v1/users.ts b/packages/types/v1/users.ts index 6dfa894ab5..38c9bc57c7 100644 --- a/packages/types/v1/users.ts +++ b/packages/types/v1/users.ts @@ -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; diff --git a/turbo.json b/turbo.json index 1f006505fc..be6605b7a0 100644 --- a/turbo.json +++ b/turbo.json @@ -5,6 +5,7 @@ "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"], "env": [ + "CRON_SECRET", "GITHUB_ID", "GITHUB_SECRET", "GOOGLE_CLIENT_ID",