mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-08 23:59:38 -06:00
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:
@@ -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=
|
||||
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
|
||||
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;
|
||||
};
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"CRON_SECRET",
|
||||
"GITHUB_ID",
|
||||
"GITHUB_SECRET",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
|
||||
Reference in New Issue
Block a user