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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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) {

View File

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

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",
],
};

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 }) => {

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

View File

@@ -31,17 +31,12 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
<div>
<Label>Role</Label>
<Select
defaultValue="admin"
value={value}
onValueChange={(v) => onChange(v as MembershipRole)}
disabled={!canDoRoleManagement}>
<SelectTrigger className="capitalize">
<SelectValue
placeholder={
<span className="text-slate-400">
{canDoRoleManagement ? "Select role" : "Select role (Pro Feature)"}
</span>
}
/>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>

View File

@@ -248,7 +248,22 @@ export const authOptions: NextAuthOptions = {
...account,
userId: userProfile.id,
});
await createProduct(team.id, { name: "My Product" });
const product = await createProduct(team.id, { name: "My Product" });
const updatedNotificationSettings = {
...userProfile.notificationSettings,
alert: {
...userProfile.notificationSettings?.alert,
},
weeklySummary: {
...userProfile.notificationSettings?.weeklySummary,
[product.id]: true,
},
};
await updateUser(userProfile.id, {
notificationSettings: updatedNotificationSettings,
});
return true;
}
}

View File

@@ -121,7 +121,7 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvite = async (inviteId: string): Promise<InviteWithCreator> => {
export const getInvite = async (inviteId: string): Promise<InviteWithCreator | null> => {
const invite = await unstable_cache(
async () => {
validateInputs([inviteId, ZString]);
@@ -140,18 +140,18 @@ export const getInvite = async (inviteId: string): Promise<InviteWithCreator> =>
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
return invite;
},
[`getInvite-${inviteId}`],
{ tags: [inviteCache.tag.byId(inviteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
return {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
};
return invite
? {
...formatDateFields(invite, ZInvite),
creator: invite.creator,
}
: null;
};
export const resendInvite = async (inviteId: string): Promise<TInvite> => {

View File

@@ -357,7 +357,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
});
if (!responsePrisma) {
throw new ResourceNotFoundError("Response", responseId);
return null;
}
const response: TResponse = {
@@ -382,10 +382,12 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
}
)();
return {
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse;
return response
? ({
...formatDateFields(response, ZResponse),
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
} as TResponse)
: null;
};
export const getResponses = async (

View File

@@ -280,7 +280,8 @@ describe("Tests for getResponse service", () => {
it("Throws ResourceNotFoundError if no response is found", async () => {
prismaMock.response.findUnique.mockResolvedValue(null);
await expect(getResponse(mockResponse.id)).rejects.toThrow(ResourceNotFoundError);
const response = await getResponse(mockResponse.id);
expect(response).toBeNull();
});
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {

View File

@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"downlevelIteration": true
}
"downlevelIteration": true,
},
}

View File

@@ -30,6 +30,7 @@ const responseSelection = {
twoFactorEnabled: true,
identityProvider: true,
objective: true,
notificationSettings: true,
};
// function to retrive basic information about a user's user

View File

@@ -13,6 +13,13 @@ export const ZUserObjective = z.enum([
export type TUserObjective = z.infer<typeof ZUserObjective>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
export const ZUser = z.object({
id: z.string(),
name: z.string().nullable(),
@@ -25,6 +32,7 @@ export const ZUser = z.object({
updatedAt: z.date(),
onboardingCompleted: z.boolean(),
objective: ZUserObjective.nullable(),
notificationSettings: ZUserNotificationSettings,
});
export type TUser = z.infer<typeof ZUser>;
@@ -37,6 +45,7 @@ export const ZUserUpdateInput = z.object({
role: ZRole.optional(),
objective: ZUserObjective.nullish(),
imageUrl: z.string().url().nullish(),
notificationSettings: ZUserNotificationSettings.optional(),
});
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
@@ -53,10 +62,3 @@ export const ZUserCreateInput = z.object({
});
export type TUserCreateInput = z.infer<typeof ZUserCreateInput>;
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;

View File

@@ -3,6 +3,6 @@
"include": ["."],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"lib": ["ES2021.String"]
}
"lib": ["ES2021.String"],
},
}