feat: enable weekly summary & support for callbacks on login (#1885)

Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
This commit is contained in:
Shubham Palriwala
2024-02-01 11:46:21 +05:30
committed by GitHub
parent 5471324cfe
commit 70fe0fb7a7
24 changed files with 180 additions and 75 deletions
@@ -15,6 +15,7 @@ import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/surve
import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { updateUser } from "@formbricks/lib/user/service";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
export const createShortUrlAction = async (url: string) => {
@@ -44,10 +45,25 @@ export async function createTeamAction(teamName: string): Promise<Team> {
accepted: true,
});
await createProduct(newTeam.id, {
const product = await createProduct(newTeam.id, {
name: "My Product",
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
return newTeam;
}
@@ -244,6 +260,20 @@ export const createProductAction = async (environmentId: string, productName: st
const product = await createProduct(team.id, {
name: productName,
});
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
},
weeklySummary: {
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(session.user.id, {
notificationSettings: updatedNotificationSettings,
});
// get production environment
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
@@ -504,7 +504,7 @@ export default function Navigation({
)}
<DropdownMenuItem
onClick={async () => {
await signOut();
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
@@ -98,8 +98,11 @@ export const leaveTeamAction = async (teamId: string) => {
};
export const createInviteTokenAction = async (inviteId: string) => {
const { email } = await getInvite(inviteId);
const inviteToken = createInviteToken(inviteId, email, {
const invite = await getInvite(inviteId);
if (!invite) {
throw new ValidationError("Invite not found");
}
const inviteToken = createInviteToken(inviteId, invite.email, {
expiresIn: "7d",
});
@@ -113,7 +113,6 @@ export default async function ProfileSettingsPage({ params }) {
</SettingsCard>
<IntegrationsTip environmentId={params.environmentId} />
<SettingsCard
beta
title="Weekly summary (Products)"
description="Stay up-to-date with a Weekly every Monday.">
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
@@ -55,7 +55,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
try {
setDeleting(true);
await deleteUserAction();
await signOut();
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
} catch (error) {
toast.error("Something went wrong");
@@ -55,7 +55,7 @@ export default function Onboarding({ session, environmentId, user, product }: On
setIsLoading(true);
try {
const updatedProfile = { ...user, onboardingCompleted: true };
const updatedProfile = { onboardingCompleted: true };
await updateUserAction(updatedProfile);
if (environmentId) {
@@ -1,6 +1,12 @@
import { Button } from "@formbricks/ui/Button";
const ContentLayout = ({ headline, description, children }) => {
interface ContentLayoutProps {
headline: string;
description: string;
children?: React.ReactNode;
}
const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => {
return (
<div className="flex h-screen">
<div className="m-auto flex flex-col gap-7 text-center text-slate-700">
@@ -57,9 +63,17 @@ export const ExpiredContent = () => {
return (
<ContentLayout
headline="Invite expired 😥"
description="Invites are valid for 7 days. Please request a new invite.">
<div></div>
</ContentLayout>
description="Invites are valid for 7 days. Please request a new invite."
/>
);
};
export const InvitationNotFound = () => {
return (
<ContentLayout
headline="Invite not found 😥"
description="The invitation code cannot be found or has already been used."
/>
);
};
+16 -10
View File
@@ -1,48 +1,54 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
import { env } from "@formbricks/lib/env.mjs";
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import {
ExpiredContent,
InvitationNotFound,
NotLoggedInContent,
RightAccountContent,
UsedContent,
WrongAccountContent,
} from "./components/InviteContentComponents";
export default async function JoinTeam({ searchParams }) {
const currentUser = await getServerSession(authOptions);
export default async function InvitePage({ searchParams }) {
const session = await getServerSession(authOptions);
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return <InvitationNotFound />;
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
if (!invite || isInviteExpired) {
if (isInviteExpired) {
return <ExpiredContent />;
} else if (invite.accepted) {
return <UsedContent />;
} else if (!currentUser) {
const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token;
} else if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
return <NotLoggedInContent email={email} token={searchParams.token} redirectUrl={redirectUrl} />;
} else if (currentUser.user?.email !== email) {
} else if (session.user?.email !== email) {
return <WrongAccountContent />;
} else {
await createMembership(invite.teamId, currentUser.user.id, { accepted: true, role: invite.role });
await createMembership(invite.teamId, session.user.id, { accepted: true, role: invite.role });
await deleteInvite(inviteId);
sendInviteAcceptedEmail(invite.creator.name ?? "", currentUser.user?.name ?? "", invite.creator.email);
sendInviteAcceptedEmail(invite.creator.name ?? "", session.user?.name ?? "", invite.creator.email);
return <RightAccountContent />;
}
} catch (e) {
return <ExpiredContent />;
console.error(e);
return <InvitationNotFound />;
}
}
+13 -16
View File
@@ -159,30 +159,27 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
return surveyFields;
};
const notificationFooter = () => {
const notificationFooter = (environmentId: string) => {
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:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
<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:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
</div>
`;
};
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
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?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">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>
${notificationFooter(notificationData.environmentId)}
`;
};
@@ -207,7 +204,7 @@ export const sendWeeklySummaryNotificationEmail = async (
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${notificationInsight(notificationData.insights)}
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
${notificationFooter()}
${notificationFooter(notificationData.environmentId)}
`),
});
};
@@ -231,7 +228,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
${createReminderNotificationBody(notificationData)}
`),
});
};
+17 -2
View File
@@ -9,7 +9,7 @@ import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createTeam, getTeam } from "@formbricks/lib/team/service";
import { createUser } from "@formbricks/lib/user/service";
import { createUser, updateUser } from "@formbricks/lib/user/service";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
@@ -76,7 +76,22 @@ export async function POST(request: Request) {
else {
const team = await createTeam({ name: user.name + "'s Team" });
await createMembership(team.id, user.id, { role: "owner", accepted: true });
await createProduct(team.id, { name: "My Product" });
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
}
// send verification email amd return user
if (!EMAIL_VERIFICATION_DISABLED) {
@@ -13,3 +13,6 @@ export const shareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
return regex.test(url);
};
export const isWebAppRoute = (url: string): boolean =>
url.startsWith("/environments") && url !== "/api/auth/signout";
+19
View File
@@ -6,14 +6,31 @@ import {
} from "@/app/middleware/bucket";
import {
clientSideApiRoute,
isWebAppRoute,
loginRoute,
shareUrlRoute,
signupRoute,
} from "@/app/middleware/endpointValidator";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
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 callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
}
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
}
@@ -54,5 +71,7 @@ export const config = {
"/api/v1/js/actions",
"/api/v1/client/storage",
"/share/(.*)/:path",
"/environments/:path*",
"/api/auth/signout",
],
};
+2
View File
@@ -63,6 +63,8 @@ test.describe("JS Package Test", async () => {
// Formbricks Modal is visible
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
await page.waitForTimeout(1000);
});
test("Admin checks Display", async ({ page }) => {
+1
View File
@@ -40,6 +40,7 @@ export const skipOnboarding = async (page: Page): Promise<void> => {
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForTimeout(500);
await page.getByRole("button", { name: "I'll do it later" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);