feat: auto subscribing to teams survey responses email (#1990)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubham Palriwala
2024-02-01 22:50:54 +05:30
committed by GitHub
parent 6cea8a2246
commit 9761483530
14 changed files with 324 additions and 94 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,9 +21,11 @@ export async function middleware(request: NextRequest) {
const token = await getToken({ req: request });
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
return NextResponse.redirect(
WEBAPP_URL + "/auth/login?callbackUrl=" + WEBAPP_URL + request.nextUrl.pathname
const loginUrl = new URL(
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
WEBAPP_URL
);
return NextResponse.redirect(loginUrl.href);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
@@ -73,5 +75,6 @@ export const config = {
"/share/(.*)/:path",
"/environments/:path*",
"/api/auth/signout",
"/auth/login",
],
};

View File

@@ -13,6 +13,7 @@ import {
} from "../constants";
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
import { getQuestionResponseMapping } from "../responses";
import { getTeamByEnvironmentId } from "../team/service";
import { withEmailTemplate } from "./email-template";
const nodemailer = require("nodemailer");
@@ -161,44 +162,52 @@ export const sendResponseFinishedEmail = async (
email: string,
environmentId: string,
survey: { id: string; name: string; questions: TSurveyQuestion[] },
response: TResponse
response: TResponse,
responseCount: number
) => {
const personEmail = response.person?.attributes["email"];
const team = await getTeamByEnvironmentId(environmentId);
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail?.toString() || MAIL_FROM,
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
survey.name
}</strong><br/>
html: withEmailTemplate(`
<h1>Hey 👋</h1>
<p>Congrats, you received a new response to your survey!
Someone just completed your survey <strong>${survey.name}</strong><br/></p>
<hr/>
<hr/>
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
${getQuestionResponseMapping(survey, response)
.map(
(question) =>
question.answer &&
`<div style="margin-top:1em;">
<p style="margin:0px;">${question.question}</p>
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
</div>`
)
.join("")}
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">View all responses</a>
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<div class="tooltip">
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
${
personEmail
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
}
</div>
<hr/>
<p><b>Don't want to get these emails?</b></p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>. <br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</i></p></div>
`),
});
};

View File

@@ -20,6 +20,7 @@ import { personCache } from "../person/cache";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
@@ -528,6 +529,8 @@ export const createSurvey = async (
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
};
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
surveyCache.revalidate({
id: survey.id,
environmentId: survey.environmentId,

View File

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

View File

@@ -24,9 +24,11 @@ import {
mockProduct,
mockSurveyOutput,
mockSurveyWithAttributesOutput,
mockTeamOutput,
mockTransformedSurveyOutput,
mockTransformedSurveyWithAttributesIdOutput,
mockTransformedSurveyWithAttributesOutput,
mockUser,
updateSurveyInput,
} from "./survey.mock";
@@ -235,6 +237,27 @@ describe("Tests for createSurvey", () => {
describe("Happy Path", () => {
it("Creates a survey successfully", async () => {
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
prismaMock.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
prismaMock.user.findMany.mockResolvedValueOnce([
{
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
},
]);
prismaMock.user.update.mockResolvedValueOnce({
...mockUser,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
role: "engineer",
});
const createdSurvey = await createSurvey(mockId, createSurveyInput);
expect(createdSurvey).toEqual(mockTransformedSurveyWithAttributesIdOutput);
});

View File

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

View File

@@ -231,6 +231,23 @@ export const deleteUser = async (id: string): Promise<TUser> => {
}
};
export const getUsersWithTeam = async (teamId: string): Promise<TUser[]> => {
validateInputs([teamId, ZId]);
const users = await prisma.user.findMany({
where: {
memberships: {
some: {
teamId,
},
},
},
select: responseSelection,
});
return users;
};
export const userIdRelatedToApiKey = async (apiKey: string) => {
const userId = await prisma.apiKey.findUnique({
where: { id: apiKey },

View File

@@ -16,6 +16,7 @@ export type TUserObjective = z.infer<typeof ZUserObjective>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
unsubscribedTeamIds: z.array(z.string()).optional(),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;