Update notification flow (#548)

This commit is contained in:
Luke Vella 2023-03-11 10:41:29 +00:00 committed by GitHub
parent cb1fb23b19
commit 39a07558ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 930 additions and 520 deletions

View file

@ -79,9 +79,10 @@
"next": "Next",
"nextMonth": "Next month",
"no": "No",
"verificationEmailSent": "An email has been sent to <b>{{email}}</b> with a link to enable notifications",
"noDatesSelected": "No dates selected",
"notificationsDisabled": "Notifications have been disabled",
"notificationsOff": "Notifications are off",
"notificationsOff": "Get notified when participants respond to your poll",
"notificationsOn": "Notifications are on",
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
"notificationsVerifyEmail": "You need to verify your email to turn on notifications",
@ -102,6 +103,7 @@
"optionCount_other": "{{count}} options",
"optionCount_two": "{{count}} options",
"optionCount_zero": "{{count}} options",
"expiredOrInvalidLink": "This link is expired or invalid. Please request a new link.",
"newParticipant": "New participant",
"pollHasBeenLocked": "This poll has been locked",
"pollHasBeenVerified": "Your poll has been verified",
@ -123,12 +125,14 @@
"specifyTimesDescription": "Include start and end times for each option",
"stepSummary": "Step {{current}} of {{total}}",
"sunday": "Sunday",
"redirect": "<a>Click here</a> if you are not redirect automatically…",
"timeFormat": "Time format:",
"timeZone": "Time Zone:",
"title": "Title",
"titlePlaceholder": "Monthly Meetup",
"today": "Today",
"unlockPoll": "Unlock poll",
"notificationsEnabled": "Notifications have been enabled for <b>{{title}}</b>",
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
"user": "User",
"userAlreadyExists": "A user with that email already exists",

View file

@ -14,7 +14,6 @@ import { useParticipants } from "./participants-provider";
import ManagePoll from "./poll/manage-poll";
import { useUpdatePollMutation } from "./poll/mutations";
import NotificationsToggle from "./poll/notifications-toggle";
import { UnverifiedPollNotice } from "./poll/unverified-poll-notice";
import { usePoll } from "./poll-context";
import Sharing from "./sharing";
import { useUser } from "./user-provider";
@ -48,6 +47,7 @@ export const AdminControls = (props: { children?: React.ReactNode }) => {
}
}, [urlId, router, updatePollMutation, t]);
// TODO (Luke Vella) [2023-03-10]: We can delete this after 2.3.0 is released
const verifyEmail = trpc.polls.verification.verify.useMutation({
onSuccess: () => {
toast.success(t("pollHasBeenVerified"));
@ -126,7 +126,6 @@ export const AdminControls = (props: { children?: React.ReactNode }) => {
) : null}
</AnimatePresence>
<m.div className="relative z-10 space-y-4" layout="position">
{poll.verified === false ? <UnverifiedPollNotice /> : null}
{props.children}
</m.div>
</div>

View file

@ -52,12 +52,13 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"btn-disabled": disabled,
"h-auto rounded-full p-2": rounded,
"w-10 p-0": !children,
"pointer-events-none": loading,
},
className,
)}
{...passThroughProps}
role="button"
disabled={disabled || loading}
disabled={disabled}
>
{loading ? (
<SpinnerIcon

View file

@ -22,7 +22,7 @@ const ErrorPage: React.FunctionComponent<ComponentProps> = ({
}) => {
const { t } = useTranslation("errors");
return (
<div className="mx-auto flex h-full max-w-full items-center justify-center px-4 py-8 lg:w-[1024px]">
<div className="flex h-[calc(100vh-100px)] w-full items-center justify-center">
<Head>
<title>{title}</title>
<meta name="robots" content="noindex,nofollow" />

View file

@ -13,9 +13,7 @@ const FullPageLoader: React.FunctionComponent<FullPageLoaderProps> = ({
className,
}) => {
return (
<div
className={clsx(" flex h-full items-center justify-center", className)}
>
<div className={clsx("flex h-full items-center justify-center", className)}>
<div className="bg-primary-500 flex items-center rounded-lg px-4 py-3 text-sm text-white shadow-sm">
<Spinner className="mr-3 h-5 animate-spin" />
{children}

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.613 15.538a1.188 1.188 0 01-1.673.155L2.996 4.933a1.188 1.188 0 111.519-1.827l12.944 10.759c.505.42.574 1.169.154 1.673z" clip-rule="evenodd"/>
<path d="M16 11.202V8a6 6 0 00-9.614-4.79L16 11.203zM4.046 7.256A6 6 0 004 8v3.586l-.707.707A1 1 0 004 14h8.16L4.045 7.256zM7.878 17.121A3 3 0 0013 15H7a3 3 0 00.878 2.121z"/>
</svg>

Before

Width:  |  Height:  |  Size: 468 B

After

Width:  |  Height:  |  Size: 445 B

Before After
Before After

View file

@ -0,0 +1,16 @@
import { NextSeo } from "next-seo";
export const AuthLayout = (
props: React.PropsWithChildren<{ title: string }>,
) => {
return (
<>
<NextSeo nofollow={true} noindex={true} title={props.title} />
<div className="bg-pattern flex h-full items-center justify-center p-4">
<div className="animate-popIn space-y-2 rounded-md border bg-white p-6 text-center shadow-sm">
{props.children}
</div>
</div>
</>
);
};

View file

@ -1,4 +1,4 @@
import { domMax, LazyMotion, m } from "framer-motion";
import { domMax, LazyMotion } from "framer-motion";
import React from "react";
import { DayjsProvider } from "@/utils/dayjs";
@ -18,7 +18,7 @@ const StandardLayout: React.FunctionComponent<{
<ModalProvider>
<div className="bg-pattern relative min-h-full" {...rest}>
<MobileNavigation />
<div className="mx-auto max-w-4xl">{children}</div>
<div className="mx-auto max-w-4xl grow">{children}</div>
</div>
</ModalProvider>
</DayjsProvider>

View file

@ -4,11 +4,20 @@ import * as React from "react";
import { Button } from "@/components/button";
import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.svg";
import { trpc } from "@/utils/trpc";
import { usePoll } from "../poll-context";
import Tooltip from "../tooltip";
import { useUpdatePollMutation } from "./mutations";
const Email = (props: { children?: React.ReactNode }) => {
return (
<span className="text-primary-300 whitespace-nowrap font-mono font-medium">
{props.children}
</span>
);
};
const NotificationsToggle: React.FunctionComponent = () => {
const { poll, urlId } = usePoll();
const { t } = useTranslation("app");
@ -16,12 +25,23 @@ const NotificationsToggle: React.FunctionComponent = () => {
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation();
const requestEnableNotifications =
trpc.polls.enableNotifications.useMutation();
return (
<Tooltip
content={
poll.verified ? (
poll.notifications ? (
<div className="max-w-md">
{requestEnableNotifications.isSuccess ? (
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{
email: poll.user.email,
}}
components={{ b: <Email /> }}
/>
) : poll.notifications ? (
<div>
<div className="text-primary-300 font-medium">
{t("notificationsOn")}
@ -34,38 +54,43 @@ const NotificationsToggle: React.FunctionComponent = () => {
email: poll.user.email,
}}
components={{
b: (
<span className="text-primary-300 whitespace-nowrap font-mono font-medium " />
),
b: <Email />,
}}
/>
</div>
</div>
) : (
t("notificationsOff")
)
) : (
t("notificationsVerifyEmail")
)
)}
</div>
}
>
<Button
loading={isUpdatingNotifications}
data-testid="notifications-toggle"
loading={
isUpdatingNotifications || requestEnableNotifications.isLoading
}
icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
disabled={!poll.verified}
onClick={() => {
setIsUpdatingNotifications(true);
updatePollMutation(
{
urlId,
notifications: !poll.notifications,
},
{
onSuccess: () => {
setIsUpdatingNotifications(false);
disabled={requestEnableNotifications.isSuccess}
onClick={async () => {
if (!poll.verified) {
await requestEnableNotifications.mutateAsync({
adminUrlId: poll.adminUrlId,
});
} else {
setIsUpdatingNotifications(true);
updatePollMutation(
{
urlId,
notifications: !poll.notifications,
},
},
);
{
onSuccess: () => {
setIsUpdatingNotifications(false);
},
},
);
}
}}
/>
</Tooltip>

View file

@ -1,44 +0,0 @@
import { Trans, useTranslation } from "next-i18next";
import { trpc } from "../../utils/trpc";
import { Button } from "../button";
import { usePoll } from "../poll-context";
export const UnverifiedPollNotice = () => {
const { t } = useTranslation("app");
const { poll } = usePoll();
const requestVerificationEmail =
trpc.polls.verification.request.useMutation();
return (
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-gray-700 shadow-sm">
<div className="px-1">
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{ email: poll.user.email }}
components={{
b: <span className="whitespace-nowrap font-bold text-slate-700" />,
}}
/>
</div>
<div>
<Button
onClick={() => {
requestVerificationEmail.mutate({
pollId: poll.id,
adminUrlId: poll.adminUrlId,
});
}}
loading={requestVerificationEmail.isLoading}
className="rounded px-3 py-2 font-semibold shadow-sm"
disabled={requestVerificationEmail.isSuccess}
>
{requestVerificationEmail.isSuccess
? "Vertification email sent"
: "Resend verification email"}
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,11 @@
import clsx from "clsx";
import SpinnerSvg from "@/components/icons/spinner.svg";
export const Spinner = (props: { className?: string }) => {
return (
<SpinnerSvg
className={clsx("inline-block h-5 animate-spin", props.className)}
/>
);
};

View file

@ -62,9 +62,13 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
],
{
onPrefetch: async (ssg, ctx) => {
await ssg.polls.getByAdminUrlId.fetch({
const poll = await ssg.polls.getByAdminUrlId.fetch({
urlId: ctx.params?.urlId as string,
});
await ssg.polls.participants.list.prefetch({
pollId: poll.id,
});
},
},
);

View file

@ -0,0 +1,126 @@
import { prisma } from "@rallly/database";
import clsx from "clsx";
import { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import React from "react";
import Bell from "@/components/icons/bell.svg";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { Spinner } from "@/components/spinner";
import {
composeGetServerSideProps,
decryptToken,
EnableNotificationsTokenPayload,
} from "@/utils/auth";
import { withPageTranslations } from "@/utils/with-page-translations";
interface PageProps {
title: string;
adminUrlId: string;
}
const Page = ({ title, adminUrlId }: PageProps) => {
const router = useRouter();
const { t } = useTranslation("app");
const [enabled, setEnabled] = React.useState(false);
React.useEffect(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
setTimeout(() => {
router.replace(`/admin/${adminUrlId}`);
}, 3000);
}, [router, adminUrlId]);
return (
<AuthLayout title={t("loading")}>
<div className="flex h-8 items-center justify-center gap-4">
{enabled ? (
<Bell
className={clsx("animate-popIn h-5", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-slate-800">
<Trans
t={t}
i18nKey="notificationsEnabled"
values={{ title }}
components={{ b: <strong /> }}
/>
</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={`/admin/${adminUrlId}`} />,
}}
/>
</div>
</AuthLayout>
);
};
const redirectToInvalidToken = {
redirect: {
destination: "/auth/invalid-token",
permanent: false,
},
};
export const getServerSideProps: GetServerSideProps = composeGetServerSideProps(
withPageTranslations(["app"]),
async (ctx) => {
const token = ctx.query.token as string;
if (!token) {
return redirectToInvalidToken;
}
const payload = await decryptToken<EnableNotificationsTokenPayload>(token);
if (payload) {
const poll = await prisma.poll.findFirst({
select: {
title: true,
},
where: { adminUrlId: payload.adminUrlId },
});
if (!poll) {
return {
redirect: {
destination: `/admin/${payload.adminUrlId}`,
permanent: false,
},
};
}
await prisma.poll.update({
data: {
notifications: true,
verified: true,
},
where: { adminUrlId: payload.adminUrlId },
});
return {
props: { title: poll.title, adminUrlId: payload.adminUrlId },
};
} else {
return redirectToInvalidToken;
}
},
);
export default Page;

View file

@ -0,0 +1,23 @@
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { withPageTranslations } from "@/utils/with-page-translations";
const Page = () => {
const { t } = useTranslation("app");
return (
<AuthLayout title={t("expiredOrInvalidLink")}>
<NextSeo
title={t("expiredOrInvalidLink")}
nofollow={true}
noindex={true}
/>
{t("expiredOrInvalidLink")}
</AuthLayout>
);
};
export const getStaticProps = withPageTranslations(["app"]);
export default Page;

View file

@ -71,7 +71,9 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
const res = await decryptToken<{ userId: string }>(
ctx.query.token as string,
);
userId = res?.userId;
if (res) {
userId = res.userId;
}
}
return {
props: {
@ -83,9 +85,13 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
],
{
onPrefetch: async (ssg, ctx) => {
await ssg.polls.getByParticipantUrlId.fetch({
const poll = await ssg.polls.getByParticipantUrlId.fetch({
urlId: ctx.params?.urlId as string,
});
await ssg.polls.participants.list.prefetch({
pollId: poll.id,
});
},
},
);

View file

@ -13,21 +13,6 @@ import {
import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc";
const sendVerificationEmail = async (
email: string,
name: string,
code: string,
) => {
await sendEmail("VerificationCodeEmail", {
to: email,
subject: `Your 6-digit code is: ${code}`,
props: {
code,
name,
},
});
};
export const auth = router({
requestRegistration: publicProcedure
.input(
@ -63,7 +48,14 @@ export const auth = router({
code,
});
await sendVerificationEmail(input.email, input.name, code);
await sendEmail("RegisterEmail", {
to: input.email,
subject: "Complete your registration",
props: {
code,
name: input.name,
},
});
return { ok: true, token };
},
@ -76,9 +68,13 @@ export const auth = router({
}),
)
.mutation(async ({ input, ctx }) => {
const { name, email, code } =
await decryptToken<RegistrationTokenPayload>(input.token);
const payload = await decryptToken<RegistrationTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { name, email, code } = payload;
if (input.code !== code) {
return { ok: false };
}
@ -128,7 +124,14 @@ export const auth = router({
code,
});
await sendVerificationEmail(input.email, user.name, code);
await sendEmail("LoginEmail", {
to: input.email,
subject: "Login",
props: {
name: user.name,
code,
},
});
return { token };
}),
@ -140,9 +143,13 @@ export const auth = router({
}),
)
.mutation(async ({ input, ctx }) => {
const { userId, code } = await decryptToken<LoginTokenPayload>(
input.token,
);
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { userId, code } = payload;
if (input.code !== code) {
return { user: null };

View file

@ -3,8 +3,9 @@ import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createToken, EnableNotificationsTokenPayload } from "@/utils/auth";
import { absoluteUrl } from "../../utils/absolute-url";
import { createToken } from "../../utils/auth";
import { nanoid } from "../../utils/nanoid";
import { GetPollApiResponse } from "../../utils/trpc/types";
import { publicProcedure, router } from "../trpc";
@ -95,7 +96,7 @@ export const polls = router({
)
.mutation(async ({ ctx, input }): Promise<{ urlId: string }> => {
const adminUrlId = await nanoid();
const participantUrlId = await nanoid();
let verified = false;
if (ctx.session.user.isGuest === false) {
@ -122,7 +123,7 @@ export const polls = router({
verified: verified,
notifications: verified,
adminUrlId,
participantUrlId: await nanoid(),
participantUrlId,
user: {
connectOrCreate: {
where: {
@ -144,39 +145,19 @@ export const polls = router({
},
});
const pollUrl = absoluteUrl(`/admin/${adminUrlId}`);
const adminLink = absoluteUrl(`/admin/${adminUrlId}`);
const participantLink = absoluteUrl(`/p/${participantUrlId}`);
try {
if (poll.verified) {
await sendEmail("NewPollEmail", {
to: input.user.email,
subject: `${poll.title} has been created`,
props: {
title: poll.title,
name: input.user.name,
adminLink: pollUrl,
},
});
} else {
const verificationCode = await createToken({
pollId: poll.id,
});
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
await sendEmail("NewPollVerificationEmail", {
to: input.user.email,
subject: `${poll.title} has been created`,
props: {
title: poll.title,
name: input.user.name,
adminLink: pollUrl,
verificationLink: verifyEmailUrl,
},
});
}
} catch (e) {
console.error(e);
}
await sendEmail("NewPollEmail", {
to: input.user.email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name: input.user.name,
adminLink,
participantLink,
},
});
return { urlId: adminUrlId };
}),
@ -265,6 +246,46 @@ export const polls = router({
comments,
verification,
// END LEGACY ROUTES
enableNotifications: publicProcedure
.input(z.object({ adminUrlId: z.string() }))
.mutation(async ({ input }) => {
const poll = await prisma.poll.findUnique({
select: {
id: true,
title: true,
user: {
select: {
name: true,
email: true,
},
},
},
where: {
adminUrlId: input.adminUrlId,
},
});
if (!poll) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const token = await createToken<EnableNotificationsTokenPayload>({
adminUrlId: input.adminUrlId,
});
await sendEmail("EnableNotificationsEmail", {
to: poll.user.email,
subject: "Please verify your email address",
props: {
name: poll.user.name,
title: poll.title,
adminLink: absoluteUrl(`/admin/${input.adminUrlId}`),
verificationLink: absoluteUrl(
`/auth/enable-notifications?token=${token}`,
),
},
});
}),
getByAdminUrlId: publicProcedure
.input(
z.object({

View file

@ -1,14 +1,8 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { absoluteUrl } from "../../../utils/absolute-url";
import {
createToken,
decryptToken,
mergeGuestsIntoUser,
} from "../../../utils/auth";
import { decryptToken, mergeGuestsIntoUser } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc";
export const verification = router({
@ -20,10 +14,19 @@ export const verification = router({
}),
)
.mutation(async ({ ctx, input }) => {
const { pollId } = await decryptToken<{
const payload = await decryptToken<{
pollId: string;
}>(input.code);
if (!payload) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid token",
});
}
const { pollId } = payload;
if (pollId !== input.pollId) {
throw new TRPCError({
code: "BAD_REQUEST",
@ -54,45 +57,4 @@ export const verification = router({
};
await ctx.session.save();
}),
request: publicProcedure
.input(
z.object({
pollId: z.string(),
adminUrlId: z.string(),
}),
)
.mutation(async ({ input: { pollId, adminUrlId } }) => {
const poll = await prisma.poll.findUnique({
where: {
id: pollId,
},
include: {
user: true,
},
});
if (!poll) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Poll with id ${pollId} not found`,
});
}
const pollUrl = absoluteUrl(`/admin/${adminUrlId}`);
const token = await createToken({
pollId,
});
const verifyEmailUrl = `${pollUrl}?code=${token}`;
await sendEmail("GuestVerifyEmail", {
to: poll.user.email,
subject: "Please verify your email address",
props: {
title: poll.title,
name: poll.user.name,
adminLink: pollUrl,
verificationLink: verifyEmailUrl,
},
});
}),
});

View file

@ -35,6 +35,10 @@ export type LoginTokenPayload = {
code: string;
};
export type EnableNotificationsTokenPayload = {
adminUrlId: string;
};
export type RegisteredUserSession = {
isGuest: false;
id: string;
@ -75,7 +79,9 @@ export function withSessionRoute(handler: NextApiHandler) {
}, sessionOptions);
}
const compose = (...fns: GetServerSideProps[]): GetServerSideProps => {
export const composeGetServerSideProps = (
...fns: GetServerSideProps[]
): GetServerSideProps => {
return async (ctx) => {
const res = { props: {} };
for (const getServerSideProps of fns) {
@ -87,7 +93,7 @@ const compose = (...fns: GetServerSideProps[]): GetServerSideProps => {
...fnRes.props,
};
} else {
return { notFound: true };
return fnRes;
}
}
@ -105,7 +111,7 @@ export function withSessionSsr(
},
): GetServerSideProps {
const composedHandler = Array.isArray(handler)
? compose(...handler)
? composeGetServerSideProps(...handler)
: handler;
return withIronSessionSsr(async (ctx) => {
@ -137,8 +143,15 @@ export function withSessionSsr(
export const decryptToken = async <P extends Record<string, unknown>>(
token: string,
): Promise<P> => {
return await unsealData(token, { password: sessionOptions.password });
): Promise<P | null> => {
const payload = await unsealData(token, {
password: sessionOptions.password,
});
if (Object.keys(payload).length === 0) {
return null;
}
return payload as P;
};
export const createToken = async <T extends Record<string, unknown>>(

View file

@ -51,15 +51,24 @@ test.describe.serial(() => {
await expect(title).toHaveText("Monthly Meetup");
pollUrl = page.url();
});
test("verify poll", async ({ page, baseURL }) => {
const { email } = await mailServer.captureOne("john.doe@email.com", {
wait: 5000,
});
expect(email.headers.subject).toBe("Monthly Meetup has been created");
expect(email.headers.subject).toBe("Let's find a date for Monthly Meetup");
pollUrl = page.url();
});
test("enable notifications", async ({ page, baseURL }) => {
await page.goto(pollUrl);
await page.getByTestId("notifications-toggle").click();
const { email } = await mailServer.captureOne("john.doe@email.com", {
wait: 5000,
});
expect(email.headers.subject).toBe("Please verify your email address");
const $ = load(email.html);
const verifyLink = $("#verifyEmailUrl").attr("href");
@ -72,6 +81,12 @@ test.describe.serial(() => {
await page.goto(verifyLink);
await expect(
page.getByText("Notifications have been enabled for Monthly Meetup"),
).toBeVisible();
page.getByText("Click here").click();
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
});

View file

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
test.describe("Edit options", () => {
test.describe("edit options", () => {
test("should show warning when deleting options with votes in them", async ({
page,
}) => {

View file

@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { APIRequestContext, expect, test } from "@playwright/test";
import { Prisma, prisma } from "@rallly/database";
import dayjs from "dayjs";
@ -8,7 +8,168 @@ import dayjs from "dayjs";
* * Polls are soft deleted after 30 days of inactivity
* * Soft deleted polls are hard deleted after 7 days of being soft deleted
*/
test.beforeAll(async ({ request, baseURL }) => {
test.describe("house keeping", () => {
const callHouseKeeping = async (
request: APIRequestContext,
baseURL?: string,
) => {
const res = await request.post(`${baseURL}/api/house-keeping`, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
},
});
return res;
};
test.beforeAll(async ({ request, baseURL }) => {
// call the endpoint to delete any existing data that needs to be removed
await callHouseKeeping(request, baseURL);
await seedData();
const res = await callHouseKeeping(request, baseURL);
expect(await res.json()).toMatchObject({
softDeleted: 1,
deleted: 2,
});
});
test("should keep active polls", async () => {
const poll = await prisma.poll.findUnique({
where: {
id: "active-poll",
},
});
// expect active poll to not be deleted
expect(poll).not.toBeNull();
expect(poll?.deleted).toBeFalsy();
});
test("should keep polls that have been soft deleted for less than 7 days", async () => {
const deletedPoll6d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-6d",
deleted: true,
},
});
// expect a poll that has been deleted for 6 days to
expect(deletedPoll6d).not.toBeNull();
});
test("should hard delete polls that have been soft deleted for 7 days", async () => {
const deletedPoll7d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-7d",
deleted: true,
},
});
expect(deletedPoll7d).toBeNull();
const participants = await prisma.participant.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(participants.length).toBe(0);
const votes = await prisma.vote.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(votes.length).toBe(0);
const options = await prisma.option.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(options.length).toBe(0);
});
test("should keep polls that are still active", async () => {
const stillActivePoll = await prisma.poll.findUnique({
where: {
id: "still-active-poll",
},
});
expect(stillActivePoll).not.toBeNull();
expect(stillActivePoll?.deleted).toBeFalsy();
});
test("should soft delete polls that are inactive", async () => {
const inactivePoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll",
deleted: true,
},
});
expect(inactivePoll).not.toBeNull();
expect(inactivePoll?.deleted).toBeTruthy();
expect(inactivePoll?.deletedAt).toBeTruthy();
});
test("should keep new demo poll", async () => {
const demoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-new",
},
});
expect(demoPoll).not.toBeNull();
});
test("should delete old demo poll", async () => {
const oldDemoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-old",
},
});
expect(oldDemoPoll).toBeNull();
});
test("should not delete poll that has options in the future", async () => {
const futureOptionPoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll-future-option",
},
});
expect(futureOptionPoll).not.toBeNull();
});
// Teardown
test.afterAll(async () => {
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"inactive-poll-future-option",
"demo-poll-new",
"demo-poll-old",
])})`;
await prisma.$executeRaw`DELETE FROM options WHERE id IN (${Prisma.join([
"option-1",
"option-2",
"option-3",
"option-4",
"option-5",
])})`;
await prisma.$executeRaw`DELETE FROM participants WHERE id IN (${Prisma.join(
["participant-1"],
)})`;
});
});
const seedData = async () => {
await prisma.poll.createMany({
data: [
// Active Poll
@ -156,151 +317,4 @@ test.beforeAll(async ({ request, baseURL }) => {
},
],
});
// call house-keeping endpoint
const res = await request.post(`${baseURL}/api/house-keeping`, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
},
});
expect(await res.json()).toMatchObject({
softDeleted: 1,
deleted: 2,
});
});
test("should keep active polls", async () => {
const poll = await prisma.poll.findUnique({
where: {
id: "active-poll",
},
});
// expect active poll to not be deleted
expect(poll).not.toBeNull();
expect(poll?.deleted).toBeFalsy();
});
test("should keep polls that have been soft deleted for less than 7 days", async () => {
const deletedPoll6d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-6d",
deleted: true,
},
});
// expect a poll that has been deleted for 6 days to
expect(deletedPoll6d).not.toBeNull();
});
test("should hard delete polls that have been soft deleted for 7 days", async () => {
const deletedPoll7d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-7d",
deleted: true,
},
});
expect(deletedPoll7d).toBeNull();
const participants = await prisma.participant.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(participants.length).toBe(0);
const votes = await prisma.vote.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(votes.length).toBe(0);
const options = await prisma.option.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(options.length).toBe(0);
});
test("should keep polls that are still active", async () => {
const stillActivePoll = await prisma.poll.findUnique({
where: {
id: "still-active-poll",
},
});
expect(stillActivePoll).not.toBeNull();
expect(stillActivePoll?.deleted).toBeFalsy();
});
test("should soft delete polls that are inactive", async () => {
const inactivePoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll",
deleted: true,
},
});
expect(inactivePoll).not.toBeNull();
expect(inactivePoll?.deleted).toBeTruthy();
expect(inactivePoll?.deletedAt).toBeTruthy();
});
test("should keep new demo poll", async () => {
const demoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-new",
},
});
expect(demoPoll).not.toBeNull();
});
test("should delete old demo poll", async () => {
const oldDemoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-old",
},
});
expect(oldDemoPoll).toBeNull();
});
test("should not delete poll that has options in the future", async () => {
const futureOptionPoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll-future-option",
},
});
expect(futureOptionPoll).not.toBeNull();
});
// Teardown
test.afterAll(async () => {
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"demo-poll-new",
"demo-poll-old",
])})`;
await prisma.$executeRaw`DELETE FROM options WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"demo-poll-new",
"demo-poll-old",
])})`;
});
};