diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 722b15ad3..3a0b64e87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: run: | echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV + echo "SUPPORT_EMAIL=support@rallly.co" >> $GITHUB_ENV - name: Install dependencies run: yarn install --frozen-lockfile diff --git a/.vscode/settings.json b/.vscode/settings.json index 8d1baba65..b9c1d0a83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "editor.codeActionsOnSave": { "source.fixAll": true }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 33708f01f..ab25177a5 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -79,9 +79,10 @@ "next": "Next", "nextMonth": "Next month", "no": "No", + "verificationEmailSent": "An email has been sent to {{email}} 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 {{email}} 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": "Click here 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 {{title}}", "unverifiedMessage": "An email has been sent to {{email}} with a link to verify the email address.", "user": "User", "userAlreadyExists": "A user with that email already exists", diff --git a/apps/web/src/components/admin-control.tsx b/apps/web/src/components/admin-control.tsx index 7e3eb3df3..aaa8c3ce0 100644 --- a/apps/web/src/components/admin-control.tsx +++ b/apps/web/src/components/admin-control.tsx @@ -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} - {poll.verified === false ? : null} {props.children} diff --git a/apps/web/src/components/button.tsx b/apps/web/src/components/button.tsx index f1d649bfc..27c3e98a8 100644 --- a/apps/web/src/components/button.tsx +++ b/apps/web/src/components/button.tsx @@ -52,12 +52,13 @@ export const Button = React.forwardRef( "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 ? ( = ({ }) => { const { t } = useTranslation("errors"); return ( -
+
{title} diff --git a/apps/web/src/components/full-page-loader.tsx b/apps/web/src/components/full-page-loader.tsx index 7f8f908df..7f36b3290 100644 --- a/apps/web/src/components/full-page-loader.tsx +++ b/apps/web/src/components/full-page-loader.tsx @@ -13,9 +13,7 @@ const FullPageLoader: React.FunctionComponent = ({ className, }) => { return ( -
+
{children} diff --git a/apps/web/src/components/icons/bell-crossed.svg b/apps/web/src/components/icons/bell-crossed.svg index 830ac9b9c..dad4d6b3c 100644 --- a/apps/web/src/components/icons/bell-crossed.svg +++ b/apps/web/src/components/icons/bell-crossed.svg @@ -1,4 +1,4 @@ - + diff --git a/apps/web/src/components/layouts/auth-layout.tsx b/apps/web/src/components/layouts/auth-layout.tsx new file mode 100644 index 000000000..a0c908713 --- /dev/null +++ b/apps/web/src/components/layouts/auth-layout.tsx @@ -0,0 +1,16 @@ +import { NextSeo } from "next-seo"; + +export const AuthLayout = ( + props: React.PropsWithChildren<{ title: string }>, +) => { + return ( + <> + +
+
+ {props.children} +
+
+ + ); +}; diff --git a/apps/web/src/components/layouts/standard-layout.tsx b/apps/web/src/components/layouts/standard-layout.tsx index e64ba4ba6..4787d3851 100644 --- a/apps/web/src/components/layouts/standard-layout.tsx +++ b/apps/web/src/components/layouts/standard-layout.tsx @@ -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<{
-
{children}
+
{children}
diff --git a/apps/web/src/components/poll/notifications-toggle.tsx b/apps/web/src/components/poll/notifications-toggle.tsx index ecb879962..50c0090f3 100644 --- a/apps/web/src/components/poll/notifications-toggle.tsx +++ b/apps/web/src/components/poll/notifications-toggle.tsx @@ -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 ( + + {props.children} + + ); +}; + 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 ( + {requestEnableNotifications.isSuccess ? ( + }} + /> + ) : poll.notifications ? (
{t("notificationsOn")} @@ -34,38 +54,43 @@ const NotificationsToggle: React.FunctionComponent = () => { email: poll.user.email, }} components={{ - b: ( - - ), + b: , }} />
) : ( t("notificationsOff") - ) - ) : ( - t("notificationsVerifyEmail") - ) + )} +
} > -
-
- ); -}; diff --git a/apps/web/src/components/spinner.tsx b/apps/web/src/components/spinner.tsx new file mode 100644 index 000000000..420db541f --- /dev/null +++ b/apps/web/src/components/spinner.tsx @@ -0,0 +1,11 @@ +import clsx from "clsx"; + +import SpinnerSvg from "@/components/icons/spinner.svg"; + +export const Spinner = (props: { className?: string }) => { + return ( + + ); +}; diff --git a/apps/web/src/pages/admin/[urlId].tsx b/apps/web/src/pages/admin/[urlId].tsx index b387450f0..52557ce6f 100644 --- a/apps/web/src/pages/admin/[urlId].tsx +++ b/apps/web/src/pages/admin/[urlId].tsx @@ -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, + }); }, }, ); diff --git a/apps/web/src/pages/auth/enable-notifications.tsx b/apps/web/src/pages/auth/enable-notifications.tsx new file mode 100644 index 000000000..c49491bad --- /dev/null +++ b/apps/web/src/pages/auth/enable-notifications.tsx @@ -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 ( + +
+ {enabled ? ( + + ) : ( + + )} +
+
+ }} + /> +
+
+ , + }} + /> +
+
+ ); +}; + +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(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; diff --git a/apps/web/src/pages/auth/invalid-token.tsx b/apps/web/src/pages/auth/invalid-token.tsx new file mode 100644 index 000000000..42fec51e5 --- /dev/null +++ b/apps/web/src/pages/auth/invalid-token.tsx @@ -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 ( + + + {t("expiredOrInvalidLink")} + + ); +}; + +export const getStaticProps = withPageTranslations(["app"]); + +export default Page; diff --git a/apps/web/src/pages/p/[urlId].tsx b/apps/web/src/pages/p/[urlId].tsx index f483c3f9d..f522ba82b 100644 --- a/apps/web/src/pages/p/[urlId].tsx +++ b/apps/web/src/pages/p/[urlId].tsx @@ -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, + }); }, }, ); diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts index 08a6be9e0..09b054513 100644 --- a/apps/web/src/server/routers/auth.ts +++ b/apps/web/src/server/routers/auth.ts @@ -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(input.token); + const payload = await decryptToken(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( - input.token, - ); + const payload = await decryptToken(input.token); + + if (!payload) { + return { user: null }; + } + + const { userId, code } = payload; if (input.code !== code) { return { user: null }; diff --git a/apps/web/src/server/routers/polls.ts b/apps/web/src/server/routers/polls.ts index 8c6289773..73084b312 100644 --- a/apps/web/src/server/routers/polls.ts +++ b/apps/web/src/server/routers/polls.ts @@ -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({ + 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({ diff --git a/apps/web/src/server/routers/polls/verification.ts b/apps/web/src/server/routers/polls/verification.ts index 9a7be9cfd..a4d3351e4 100644 --- a/apps/web/src/server/routers/polls/verification.ts +++ b/apps/web/src/server/routers/polls/verification.ts @@ -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, - }, - }); - }), }); diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index 8c5593639..67c2f9048 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -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

>( token: string, -): Promise

=> { - return await unsealData(token, { password: sessionOptions.password }); +): Promise

=> { + const payload = await unsealData(token, { + password: sessionOptions.password, + }); + if (Object.keys(payload).length === 0) { + return null; + } + + return payload as P; }; export const createToken = async >( diff --git a/apps/web/tests/create-delete-poll.spec.ts b/apps/web/tests/create-delete-poll.spec.ts index fe3875a61..0b2bb10bd 100644 --- a/apps/web/tests/create-delete-poll.spec.ts +++ b/apps/web/tests/create-delete-poll.spec.ts @@ -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"); }); diff --git a/apps/web/tests/edit-options.spec.ts b/apps/web/tests/edit-options.spec.ts index 343a638cb..2c481f1f2 100644 --- a/apps/web/tests/edit-options.spec.ts +++ b/apps/web/tests/edit-options.spec.ts @@ -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, }) => { diff --git a/apps/web/tests/house-keeping.spec.ts b/apps/web/tests/house-keeping.spec.ts index 886a262d0..36f6cb4d7 100644 --- a/apps/web/tests/house-keeping.spec.ts +++ b/apps/web/tests/house-keeping.spec.ts @@ -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", - ])})`; -}); +}; diff --git a/packages/emails/src/send-email.tsx b/packages/emails/src/send-email.tsx index a927680d4..74b0603d2 100644 --- a/packages/emails/src/send-email.tsx +++ b/packages/emails/src/send-email.tsx @@ -46,12 +46,19 @@ export const sendEmail = async ( templateName: T, options: SendEmailOptions, ) => { + if (!process.env.SUPPORT_EMAIL) { + console.info("SUPPORT_EMAIL not configured - skipping email send"); + return; + } const transport = getTransport(); const Template = templates[templateName] as TemplateComponent; try { return await transport.sendMail({ - from: process.env.SUPPORT_EMAIL, + from: { + name: "Rallly", + address: process.env.SUPPORT_EMAIL, + }, to: options.to, subject: options.subject, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/emails/src/templates.ts b/packages/emails/src/templates.ts index 563e97bcc..114a31aaa 100644 --- a/packages/emails/src/templates.ts +++ b/packages/emails/src/templates.ts @@ -1,7 +1,7 @@ -export * from "./templates/guest-verify-email"; +export * from "./templates/login"; export * from "./templates/new-comment"; export * from "./templates/new-participant"; export * from "./templates/new-participant-confirmation"; export * from "./templates/new-poll"; -export * from "./templates/new-poll-verification"; -export * from "./templates/verification-code"; +export * from "./templates/register"; +export * from "./templates/turn-on-notifications"; diff --git a/packages/emails/src/templates/components/email-layout.tsx b/packages/emails/src/templates/components/email-layout.tsx index 7c76540a2..a76252558 100644 --- a/packages/emails/src/templates/components/email-layout.tsx +++ b/packages/emails/src/templates/components/email-layout.tsx @@ -4,6 +4,7 @@ import { Body, Container, Head, + Hr, Html, Img, Link, @@ -12,14 +13,24 @@ import { } from "@react-email/components"; import { Tailwind } from "@react-email/tailwind"; -export const EmailLayout = (props: { - children: React.ReactNode; +import { SmallText, Text } from "./styled-components"; + +interface EmailLayoutProps { preview: string; -}) => { + recipientName: string; + footNote?: React.ReactNode; +} + +export const EmailLayout = ({ + preview, + recipientName = "Guest", + children, + footNote, +}: React.PropsWithChildren) => { return ( - {props.preview} + {preview} - - + +

Rallly
-
{props.children}
+
+ Hi {recipientName}, + {children} + {footNote ? ( + <> +
+ {footNote} + + ) : null} +
Home diff --git a/packages/emails/src/templates/components/new-poll-base.tsx b/packages/emails/src/templates/components/new-poll-base.tsx index 69bd8fb08..6e3db565d 100644 --- a/packages/emails/src/templates/components/new-poll-base.tsx +++ b/packages/emails/src/templates/components/new-poll-base.tsx @@ -1,6 +1,9 @@ +import { absoluteUrl } from "@rallly/utils"; +import { Hr } from "@react-email/components"; import { Container } from "@react-email/container"; -import { Link, Text } from "./styled-components"; +import { Link, SmallText, Text } from "./styled-components"; +import { removeProtocalFromUrl } from "./utils"; export interface NewPollBaseEmailProps { title: string; @@ -29,6 +32,12 @@ export const NewPollBaseEmail = ({ {children} +
+ + You are receiving this email because a new poll was created with this + email address on{" "} + {removeProtocalFromUrl(absoluteUrl())} + ); }; diff --git a/packages/emails/src/templates/components/notification-email.tsx b/packages/emails/src/templates/components/notification-email.tsx new file mode 100644 index 000000000..4ed01b698 --- /dev/null +++ b/packages/emails/src/templates/components/notification-email.tsx @@ -0,0 +1,47 @@ +import { EmailLayout } from "./email-layout"; +import { Link, Section } from "./styled-components"; + +export interface NotificationBaseProps { + name: string; + title: string; + pollUrl: string; + unsubscribeUrl: string; +} + +export interface NotificationEmailProps extends NotificationBaseProps { + preview: string; +} + +export const NotificationEmail = ({ + name, + title, + pollUrl, + unsubscribeUrl, + preview, + children, +}: React.PropsWithChildren) => { + return ( + + You're receiving this email because notifications are enabled for{" "} + {title}. If you want to stop receiving emails about + this event you can{" "} + + turn notifications off + + . + + } + preview={preview} + > + {children} +
+ Go to poll → +
+
+ ); +}; + +export default NotificationEmail; diff --git a/packages/emails/src/templates/components/styled-components.tsx b/packages/emails/src/templates/components/styled-components.tsx index 24016580a..613d4efaf 100644 --- a/packages/emails/src/templates/components/styled-components.tsx +++ b/packages/emails/src/templates/components/styled-components.tsx @@ -39,7 +39,7 @@ export const Link = (props: LinkProps) => { return ( ); }; @@ -61,3 +61,12 @@ export const Section = (props: SectionProps) => { ); }; + +export const SmallText = (props: TextProps) => { + return ( + + ); +}; diff --git a/packages/emails/src/templates/components/utils.ts b/packages/emails/src/templates/components/utils.ts new file mode 100644 index 000000000..0198f1275 --- /dev/null +++ b/packages/emails/src/templates/components/utils.ts @@ -0,0 +1,3 @@ +export const removeProtocalFromUrl = (url: string) => { + return url.replace(/(^\w+:|^)\/\//, ""); +}; diff --git a/packages/emails/src/templates/guest-verify-email.tsx b/packages/emails/src/templates/guest-verify-email.tsx deleted file mode 100644 index 164488e91..000000000 --- a/packages/emails/src/templates/guest-verify-email.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Button, Container } from "@react-email/components"; - -import { EmailLayout } from "./components/email-layout"; -import { Section, Text } from "./components/styled-components"; - -type GuestVerifyEmailProps = { - title: string; - name: string; - verificationLink: string; - adminLink: string; -}; - -export const GuestVerifyEmail = ({ - title = "Untitled Poll", - name = "Guest", - verificationLink = "https://rallly.co", -}: GuestVerifyEmailProps) => { - return ( - - - Hi {name}, - - To receive notifications for "{title}" you - will need to verify your email address. - - To verify your email please click the button below. -
- -
-
-
- ); -}; - -export default GuestVerifyEmail; diff --git a/packages/emails/src/templates/login.tsx b/packages/emails/src/templates/login.tsx new file mode 100644 index 000000000..09a855fbf --- /dev/null +++ b/packages/emails/src/templates/login.tsx @@ -0,0 +1,55 @@ +import { absoluteUrl } from "@rallly/utils"; + +import { EmailLayout } from "./components/email-layout"; +import { Heading, Link, Text } from "./components/styled-components"; +import { removeProtocalFromUrl } from "./components/utils"; + +interface LoginEmailProps { + name: string; + code: string; + // magicLink: string; +} + +export const LoginEmail = ({ + name = "Guest", + code = "123456", +}: // magicLink = "https://rallly.co", +LoginEmailProps) => { + return ( + + You're receiving this email because a request was made to login + to{" "} + + {removeProtocalFromUrl(absoluteUrl())} + + . If this wasn't you, let us know by replying to this email. + + } + recipientName={name} + preview={`Your 6-digit code: ${code}`} + > + Your 6-digit code is: + + {code} + + + Use this code to complete the verification process on{" "} + {removeProtocalFromUrl(absoluteUrl())} + + + + This code is valid for 15 minutes + + + {/* Magic link + + Alternatively, you can login by using this{" "} + magic link ✨ + */} + + ); +}; + +export default LoginEmail; diff --git a/packages/emails/src/templates/new-comment.tsx b/packages/emails/src/templates/new-comment.tsx index 4824b7f58..f6110e22b 100644 --- a/packages/emails/src/templates/new-comment.tsx +++ b/packages/emails/src/templates/new-comment.tsx @@ -1,12 +1,10 @@ -import { EmailLayout } from "./components/email-layout"; -import { Button, Link, Section, Text } from "./components/styled-components"; +import NotificationEmail, { + NotificationBaseProps, +} from "./components/notification-email"; +import { Text } from "./components/styled-components"; -export interface NewCommentEmailProps { - name: string; - title: string; +export interface NewCommentEmailProps extends NotificationBaseProps { authorName: string; - pollUrl: string; - unsubscribeUrl: string; } export const NewCommentEmail = ({ @@ -17,20 +15,17 @@ export const NewCommentEmail = ({ unsubscribeUrl = "https://rallly.co", }: NewCommentEmailProps) => { return ( - - Hi {name}, + {authorName} has commented on {title}. -
- -
- - - Stop receiving notifications for this poll. - - -
+ ); }; diff --git a/packages/emails/src/templates/new-participant-confirmation.tsx b/packages/emails/src/templates/new-participant-confirmation.tsx index 43ec9b1a6..0d4cc22a3 100644 --- a/packages/emails/src/templates/new-participant-confirmation.tsx +++ b/packages/emails/src/templates/new-participant-confirmation.tsx @@ -12,8 +12,13 @@ export const NewParticipantConfirmationEmail = ({ editSubmissionUrl = "https://rallly.co", }: NewParticipantConfirmationEmailProps) => { return ( - - Hi {name}, + You are receiving this email because a response was submitting + } + recipientName={name} + preview="To edit your response use the link below" + > Thank you for submitting your availability for {title}. diff --git a/packages/emails/src/templates/new-participant.tsx b/packages/emails/src/templates/new-participant.tsx index 41b84de06..7fb734521 100644 --- a/packages/emails/src/templates/new-participant.tsx +++ b/packages/emails/src/templates/new-participant.tsx @@ -1,12 +1,10 @@ -import { EmailLayout } from "./components/email-layout"; -import { Button, Link, Section, Text } from "./components/styled-components"; +import NotificationEmail, { + NotificationBaseProps, +} from "./components/notification-email"; +import { Text } from "./components/styled-components"; -export interface NewParticipantEmailProps { - name: string; - title: string; +export interface NewParticipantEmailProps extends NotificationBaseProps { participantName: string; - pollUrl: string; - unsubscribeUrl: string; } export const NewParticipantEmail = ({ @@ -17,21 +15,18 @@ export const NewParticipantEmail = ({ unsubscribeUrl = "https://rallly.co", }: NewParticipantEmailProps) => { return ( - - Hi {name}, + - {participantName} has shared their availability for{" "} + {participantName} has responded to{" "} {title}. -
- -
- - - Stop receiving notifications for this poll. - - -
+ ); }; diff --git a/packages/emails/src/templates/new-poll-verification.tsx b/packages/emails/src/templates/new-poll-verification.tsx deleted file mode 100644 index 6d71ff38d..000000000 --- a/packages/emails/src/templates/new-poll-verification.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { EmailLayout } from "./components/email-layout"; -import { - NewPollBaseEmail, - NewPollBaseEmailProps, -} from "./components/new-poll-base"; -import { Button, Heading, Section, Text } from "./components/styled-components"; - -export interface NewPollVerificationEmailProps extends NewPollBaseEmailProps { - verificationLink: string; -} - -export const NewPollVerificationEmail = ({ - title = "Untitled Poll", - name = "Guest", - verificationLink = "https://rallly.co", - adminLink = "https://rallly.co/admin/abcdefg123", -}: NewPollVerificationEmailProps) => { - return ( - - -
- - Want to get notified when participants vote? - - Verify your email address to turn on notifications. -
- -
-
-
-
- ); -}; - -export default NewPollVerificationEmail; diff --git a/packages/emails/src/templates/new-poll.tsx b/packages/emails/src/templates/new-poll.tsx index ce81fa8bc..2939d31dd 100644 --- a/packages/emails/src/templates/new-poll.tsx +++ b/packages/emails/src/templates/new-poll.tsx @@ -1,17 +1,93 @@ +import { absoluteUrl } from "@rallly/utils"; + import { EmailLayout } from "./components/email-layout"; -import { - NewPollBaseEmail, - NewPollBaseEmailProps, -} from "./components/new-poll-base"; +import { Heading, Link, Section, Text } from "./components/styled-components"; +import { removeProtocalFromUrl } from "./components/utils"; + +export interface NewPollEmailProps { + title: string; + name: string; + adminLink: string; + participantLink: string; +} + +const ShareLink = ({ + title, + participantLink, + name, + children, +}: React.PropsWithChildren<{ + name: string; + title: string; + participantLink: string; +}>) => { + return ( + + {children} + + ); +}; + +const LinkContainer = (props: { link: string }) => { + return ( +
+ + {props.link} + +
+ ); +}; export const NewPollEmail = ({ title = "Untitled Poll", name = "Guest", adminLink = "https://rallly.co/admin/abcdefg123", -}: NewPollBaseEmailProps) => { + participantLink = "https://rallly.co/p/wxyz9876", +}: NewPollEmailProps) => { return ( - - + + You are receiving this email because a new poll was created with this + email address on{" "} + + {removeProtocalFromUrl(absoluteUrl())} + + . If this wasn't you, please ignore this email. + + } + recipientName={name} + preview="Share your participant link to start collecting responses." + > + + Your new poll is ready! Now lets find a date for{" "} + {title}. + + + Copy this link and share it with your participants to start collecting + responses. + + + + + Share via email → + + + Your secret link + + Use this link to access the admin page where you can view and edit your + poll. + + + + Go to admin page → + ); }; diff --git a/packages/emails/src/templates/register.tsx b/packages/emails/src/templates/register.tsx new file mode 100644 index 000000000..4edf63411 --- /dev/null +++ b/packages/emails/src/templates/register.tsx @@ -0,0 +1,47 @@ +import { absoluteUrl } from "@rallly/utils"; +import { Heading } from "@react-email/heading"; + +import { EmailLayout } from "./components/email-layout"; +import { Link, Text } from "./components/styled-components"; +import { removeProtocalFromUrl } from "./components/utils"; + +interface RegisterEmailProps { + name: string; + code: string; +} + +export const RegisterEmail = ({ + name = "Guest", + code = "123456", +}: RegisterEmailProps) => { + return ( + + You're receiving this email because a request was made to + register an account on{" "} + + {removeProtocalFromUrl(absoluteUrl())} + + . + + } + recipientName={name} + preview={`Your 6-digit code is: ${code}`} + > + Your 6-digit code is: + + {code} + + + Use this code to complete the verification process on{" "} + {removeProtocalFromUrl(absoluteUrl())} + + + This code is valid for 15 minutes + + + ); +}; + +export default RegisterEmail; diff --git a/packages/emails/src/templates/turn-on-notifications.tsx b/packages/emails/src/templates/turn-on-notifications.tsx new file mode 100644 index 000000000..4a2d1f419 --- /dev/null +++ b/packages/emails/src/templates/turn-on-notifications.tsx @@ -0,0 +1,51 @@ +import { EmailLayout } from "./components/email-layout"; +import { + Button, + Link, + Section, + SmallText, + Text, +} from "./components/styled-components"; + +type EnableNotificationsEmailProps = { + title: string; + name: string; + verificationLink: string; + adminLink: string; +}; + +export const EnableNotificationsEmail = ({ + title = "Untitled Poll", + name = "Guest", + verificationLink = "https://rallly.co", + adminLink = "https://rallly.co", +}: EnableNotificationsEmailProps) => { + return ( + + You are receiving this email because a request was made to enable + notifications for {title}. + + } + > + + Before we can send you notifications we need to verify your email. + + + Click the button below to complete the email verification and enable + notifications for {title}. + +
+ +
+ The link will expire in 15 minutes. +
+ ); +}; + +export default EnableNotificationsEmail; diff --git a/packages/emails/src/templates/verification-code.tsx b/packages/emails/src/templates/verification-code.tsx deleted file mode 100644 index 28d8ae50d..000000000 --- a/packages/emails/src/templates/verification-code.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Heading } from "@react-email/heading"; - -import { EmailLayout } from "./components/email-layout"; -import { Section, Text } from "./components/styled-components"; - -interface VerificationCodeEmailProps { - name: string; - code: string; -} - -export const VerificationCodeEmail = ({ - name = "Guest", - code = "123456", -}: VerificationCodeEmailProps) => { - return ( - - Hi {name}, - Please use the code below to verify your email address. -
- Your 6-digit code is: - - {code} - - - - This code is valid for 15 minutes - - -
-
- ); -}; - -export default VerificationCodeEmail;