From 70fe0fb7a727cca5130833c4577ba0195dd0a16f Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Thu, 1 Feb 2024 11:46:21 +0530 Subject: [PATCH] feat: enable weekly summary & support for callbacks on login (#1885) Co-authored-by: Matti Nannt Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> --- .devcontainer/devcontainer.json | 6 ++-- .../environments/[environmentId]/actions.ts | 32 ++++++++++++++++++- .../[environmentId]/components/Navigation.tsx | 2 +- .../settings/members/actions.ts | 7 ++-- .../settings/notifications/page.tsx | 1 - .../profile/components/DeleteAccount.tsx | 2 +- .../onboarding/components/Onboarding.tsx | 2 +- .../components/InviteContentComponents.tsx | 22 ++++++++++--- apps/web/app/(auth)/invite/page.tsx | 26 +++++++++------ apps/web/app/api/cron/weekly_summary/email.ts | 29 ++++++++--------- apps/web/app/api/v1/users/route.ts | 19 +++++++++-- apps/web/app/middleware/endpointValidator.ts | 3 ++ apps/web/middleware.ts | 19 +++++++++++ apps/web/playwright/js.spec.ts | 2 ++ apps/web/playwright/utils/helper.ts | 1 + .../components/AddMemberRole.tsx | 9 ++---- packages/lib/authOptions.ts | 17 +++++++++- packages/lib/invite/service.ts | 16 +++++----- packages/lib/response/service.ts | 12 ++++--- packages/lib/response/tests/response.unit.ts | 3 +- packages/lib/tsconfig.json | 4 +-- packages/lib/user/service.ts | 1 + packages/types/user.ts | 16 ++++++---- packages/ui/tsconfig.json | 4 +-- 24 files changed, 180 insertions(+), 75 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5ec2fa7ced..301aed0751 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,8 +12,8 @@ // Configure properties specific to VS Code. "vscode": { // Add the IDs of extensions you want installed when the container is created. - "extensions": ["dbaeumer.vscode-eslint"] - } + "extensions": ["dbaeumer.vscode-eslint"], + }, }, // Use 'forwardPorts' to make a list of ports inside the container available locally. @@ -25,5 +25,5 @@ "postAttachCommand": "pnpm dev --filter=web... --filter=demo...", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "node" + "remoteUser": "node", } diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 3b8510ff75..80f5df948c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -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 { 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"); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx index 8faf4ad6f1..0f392b973d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx @@ -504,7 +504,7 @@ export default function Navigation({ )} { - await signOut(); + await signOut({ callbackUrl: "/auth/login" }); await formbricksLogout(); }}>
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts index 91a024e82b..7dab97c425 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/actions.ts @@ -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", }); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/notifications/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/notifications/page.tsx index 211be23896..3a4b617722 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/notifications/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/notifications/page.tsx @@ -113,7 +113,6 @@ export default async function ProfileSettingsPage({ params }) { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/components/DeleteAccount.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/components/DeleteAccount.tsx index 60d1c05cf9..63c6358856 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/profile/components/DeleteAccount.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/components/DeleteAccount.tsx @@ -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"); diff --git a/apps/web/app/(app)/onboarding/components/Onboarding.tsx b/apps/web/app/(app)/onboarding/components/Onboarding.tsx index 93a583189c..bbab2da093 100644 --- a/apps/web/app/(app)/onboarding/components/Onboarding.tsx +++ b/apps/web/app/(app)/onboarding/components/Onboarding.tsx @@ -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) { diff --git a/apps/web/app/(auth)/invite/components/InviteContentComponents.tsx b/apps/web/app/(auth)/invite/components/InviteContentComponents.tsx index 1c399da823..ce541d6c99 100644 --- a/apps/web/app/(auth)/invite/components/InviteContentComponents.tsx +++ b/apps/web/app/(auth)/invite/components/InviteContentComponents.tsx @@ -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 (
@@ -57,9 +63,17 @@ export const ExpiredContent = () => { return ( -
-
+ description="Invites are valid for 7 days. Please request a new invite." + /> + ); +}; + +export const InvitationNotFound = () => { + return ( + ); }; diff --git a/apps/web/app/(auth)/invite/page.tsx b/apps/web/app/(auth)/invite/page.tsx index 9e451f9500..9c707fb13e 100644 --- a/apps/web/app/(auth)/invite/page.tsx +++ b/apps/web/app/(auth)/invite/page.tsx @@ -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 ; + } + const isInviteExpired = new Date(invite.expiresAt) < new Date(); - if (!invite || isInviteExpired) { + if (isInviteExpired) { return ; } else if (invite.accepted) { return ; - } else if (!currentUser) { - const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token; + } else if (!session) { + const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token; return ; - } else if (currentUser.user?.email !== email) { + } else if (session.user?.email !== email) { return ; } 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 ; } } catch (e) { - return ; + console.error(e); + return ; } } diff --git a/apps/web/app/api/cron/weekly_summary/email.ts b/apps/web/app/api/cron/weekly_summary/email.ts index 97d002a10e..e3b59b02ee 100644 --- a/apps/web/app/api/cron/weekly_summary/email.ts +++ b/apps/web/app/api/cron/weekly_summary/email.ts @@ -159,30 +159,27 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => { return surveyFields; }; -const notificationFooter = () => { +const notificationFooter = (environmentId: string) => { return ` -

All the best,

-

The Formbricks Team 🀍

-

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

- `; +

All the best,

+

The Formbricks Team 🀍

+
+

To halt Weekly Updates, please turn them off in your settings πŸ™

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

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

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

- Setup a new survey - + Setup a new survey +

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

- - -

All the best,

-

The Formbricks Team

- -

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

+ ${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)} `), }); }; diff --git a/apps/web/app/api/v1/users/route.ts b/apps/web/app/api/v1/users/route.ts index 10457bddd5..7ed0b20816 100644 --- a/apps/web/app/api/v1/users/route.ts +++ b/apps/web/app/api/v1/users/route.ts @@ -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) { diff --git a/apps/web/app/middleware/endpointValidator.ts b/apps/web/app/middleware/endpointValidator.ts index 1515a9f8c9..bc38ccc869 100644 --- a/apps/web/app/middleware/endpointValidator.ts +++ b/apps/web/app/middleware/endpointValidator.ts @@ -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"; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index e3df726d6a..b7e0338737 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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", ], }; diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index c5636c0c37..6af46e96cb 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -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 }) => { diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index c12078156c..b371cbc464 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -40,6 +40,7 @@ export const skipOnboarding = async (page: Page): Promise => { 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/); diff --git a/packages/ee/RoleManagement/components/AddMemberRole.tsx b/packages/ee/RoleManagement/components/AddMemberRole.tsx index c552a81af0..7b94d49e2d 100644 --- a/packages/ee/RoleManagement/components/AddMemberRole.tsx +++ b/packages/ee/RoleManagement/components/AddMemberRole.tsx @@ -31,17 +31,12 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro