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 <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Bhaskar Singh
2023-07-06 19:17:33 +05:30
committed by GitHub
parent 896e91a38b
commit 9e9db7103e
18 changed files with 735 additions and 128 deletions

View File

@@ -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=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=

View 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

View 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>Wed love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys" 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)}
`),
});
};

View 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,
},
},
},
},
},
},
},
});
};

View 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;
};

View File

@@ -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>

View File

@@ -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();
}}
/>
);
}

View File

@@ -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`}>

View File

@@ -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>
</>
))}
</>
);
}

View File

@@ -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();
}}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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");

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,6 +1,8 @@
export interface NotificationSettings {
[surveyId: string]: {
responseFinished: boolean;
weeklySummary: boolean;
alert: {
[surveyId: string]: boolean;
};
weeklySummary: {
[productId: string]: boolean;
};
}

View File

@@ -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>;

View File

@@ -5,6 +5,7 @@
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"env": [
"CRON_SECRET",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_CLIENT_ID",