Allow participant to enter email to receive edit link (#534)

This commit is contained in:
Luke Vella 2023-03-03 16:50:50 +00:00 committed by GitHub
parent aab999598e
commit 0ac3c95755
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 230 additions and 81 deletions

View file

@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = {
use: { use: {
viewport: { width: 1280, height: 720 }, viewport: { width: 1280, height: 720 },
baseURL, baseURL,
permissions: ["clipboard-read"],
trace: "retain-on-failure", trace: "retain-on-failure",
}, },
webServer: { webServer: {

View file

@ -69,7 +69,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { register, formState, handleSubmit } = const { register, formState, handleSubmit } =
useForm<NewParticipantFormData>(); useForm<NewParticipantFormData>();
const { requiredString } = useFormValidation(); const { requiredString, validEmail } = useFormValidation();
const { poll } = usePoll(); const { poll } = usePoll();
const addParticipant = useAddParticipantMutation(); const addParticipant = useAddParticipantMutation();
return ( return (
@ -83,6 +83,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
const newParticipant = await addParticipant.mutateAsync({ const newParticipant = await addParticipant.mutateAsync({
name: data.name, name: data.name,
votes: props.votes, votes: props.votes,
email: data.email,
pollId: poll.id, pollId: poll.id,
}); });
props.onSubmit?.(newParticipant); props.onSubmit?.(newParticipant);
@ -107,7 +108,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
</div> </div>
) : null} ) : null}
</fieldset> </fieldset>
{/* <fieldset> <fieldset>
<label htmlFor="email" className="text-slate-500"> <label htmlFor="email" className="text-slate-500">
{t("email")} ({t("optional")}) {t("email")} ({t("optional")})
</label> </label>
@ -128,7 +129,7 @@ export const NewParticipantModal = (props: NewParticipantModalProps) => {
{formState.errors.email.message} {formState.errors.email.message}
</div> </div>
) : null} ) : null}
</fieldset> */} </fieldset>
<fieldset> <fieldset>
<label className="text-slate-500">{t("response")}</label> <label className="text-slate-500">{t("response")}</label>
<VoteSummary <VoteSummary

View file

@ -58,7 +58,7 @@ export const PollContextProvider: React.FunctionComponent<{
}> = ({ poll, urlId, admin, children }) => { }> = ({ poll, urlId, admin, children }) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { participants } = useParticipants(); const { participants } = useParticipants();
const { user } = useUser(); const { user, ownsObject } = useUser();
const [targetTimeZone, setTargetTimeZone] = const [targetTimeZone, setTargetTimeZone] =
React.useState(getBrowserTimeZone); React.useState(getBrowserTimeZone);
@ -108,9 +108,7 @@ export const PollContextProvider: React.FunctionComponent<{
}; };
const userAlreadyVoted = const userAlreadyVoted =
user && participants user && participants ? participants.some(ownsObject) : false;
? participants.some((participant) => participant.userId === user.id)
: false;
const optionIds = parsedOptions.options.map(({ optionId }) => optionId); const optionIds = parsedOptions.options.map(({ optionId }) => optionId);
@ -160,6 +158,7 @@ export const PollContextProvider: React.FunctionComponent<{
}, [ }, [
admin, admin,
getScore, getScore,
ownsObject,
participants, participants,
poll, poll,
targetTimeZone, targetTimeZone,

View file

@ -69,7 +69,10 @@ export const ParticipantRowView: React.FunctionComponent<{
<Dropdown <Dropdown
placement="bottom-start" placement="bottom-start"
trigger={ trigger={
<button className="text-slate-500 hover:text-slate-800"> <button
data-testid="participant-menu"
className="text-slate-500 hover:text-slate-800"
>
<DotsVertical className="h-3" /> <DotsVertical className="h-3" />
</button> </button>
} }

View file

@ -56,7 +56,10 @@ const MobilePoll: React.FunctionComponent = () => {
const { reset, handleSubmit, formState } = form; const { reset, handleSubmit, formState } = form;
const [selectedParticipantId, setSelectedParticipantId] = React.useState< const [selectedParticipantId, setSelectedParticipantId] = React.useState<
string | undefined string | undefined
>(); >(() => {
const participant = participants.find((p) => session.ownsObject(p));
return participant?.id;
});
const selectedParticipant = selectedParticipantId const selectedParticipant = selectedParticipantId
? getParticipantById(selectedParticipantId) ? getParticipantById(selectedParticipantId)

View file

@ -15,9 +15,9 @@ export const normalizeVotes = (
export const useAddParticipantMutation = () => { export const useAddParticipantMutation = () => {
return trpc.polls.participants.add.useMutation({ return trpc.polls.participants.add.useMutation({
onSuccess: (participant) => { onSuccess: (_, { name }) => {
posthog.capture("add participant", { posthog.capture("add participant", {
name: participant.name, name,
}); });
}, },
}); });

View file

@ -47,7 +47,10 @@ export const IfGuest = (props: { children?: React.ReactNode }) => {
return <>{props.children}</>; return <>{props.children}</>;
}; };
export const UserProvider = (props: { children?: React.ReactNode }) => { export const UserProvider = (props: {
children?: React.ReactNode;
forceUserId?: string;
}) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const queryClient = trpc.useContext(); const queryClient = trpc.useContext();
@ -105,7 +108,10 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
return queryClient.whoami.invalidate(); return queryClient.whoami.invalidate();
}, },
ownsObject: ({ userId }) => { ownsObject: ({ userId }) => {
if (userId && user.id === userId) { if (
(userId && user.id === userId) ||
(props.forceUserId && props.forceUserId === userId)
) {
return true; return true;
} }
return false; return false;

View file

@ -6,8 +6,8 @@ import { useTranslation } from "next-i18next";
import { ParticipantsProvider } from "@/components/participants-provider"; import { ParticipantsProvider } from "@/components/participants-provider";
import { Poll } from "@/components/poll"; import { Poll } from "@/components/poll";
import { PollContextProvider } from "@/components/poll-context"; import { PollContextProvider } from "@/components/poll-context";
import { useUser } from "@/components/user-provider"; import { UserProvider, useUser } from "@/components/user-provider";
import { withSessionSsr } from "@/utils/auth"; import { decryptToken, withSessionSsr } from "@/utils/auth";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
import { withPageTranslations } from "@/utils/with-page-translations"; import { withPageTranslations } from "@/utils/with-page-translations";
@ -15,7 +15,10 @@ import StandardLayout from "../../components/layouts/standard-layout";
import ModalProvider from "../../components/modal/modal-provider"; import ModalProvider from "../../components/modal/modal-provider";
import { NextPageWithLayout } from "../../types"; import { NextPageWithLayout } from "../../types";
const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => { const Page: NextPageWithLayout<{
urlId: string;
forceUserId: string | null;
}> = ({ urlId, forceUserId }) => {
const pollQuery = trpc.polls.getByParticipantUrlId.useQuery({ urlId }); const pollQuery = trpc.polls.getByParticipantUrlId.useQuery({ urlId });
const { user } = useUser(); const { user } = useUser();
@ -29,23 +32,25 @@ const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
<title>{poll.title}</title> <title>{poll.title}</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</Head> </Head>
<ParticipantsProvider pollId={poll.id}> <UserProvider forceUserId={forceUserId ?? undefined}>
<PollContextProvider poll={poll} urlId={urlId} admin={false}> <ParticipantsProvider pollId={poll.id}>
<ModalProvider> <PollContextProvider poll={poll} urlId={urlId} admin={false}>
<div className="space-y-3 sm:space-y-4"> <ModalProvider>
{user.id === poll.user.id ? ( <div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
<Link {user.id === poll.user.id ? (
className="btn-default" <Link
href={`/admin/${poll.adminUrlId}`} className="btn-default"
> href={`/admin/${poll.adminUrlId}`}
&larr; {t("goToAdmin")} >
</Link> &larr; {t("goToAdmin")}
) : null} </Link>
<Poll /> ) : null}
</div> <Poll />
</ModalProvider> </div>
</PollContextProvider> </ModalProvider>
</ParticipantsProvider> </PollContextProvider>
</ParticipantsProvider>
</UserProvider>
</> </>
); );
} }
@ -61,9 +66,17 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
[ [
withPageTranslations(["common", "app", "errors"]), withPageTranslations(["common", "app", "errors"]),
async (ctx) => { async (ctx) => {
let userId: string | null = null;
if (ctx.query.token) {
const res = await decryptToken<{ userId: string }>(
ctx.query.token as string,
);
userId = res?.userId;
}
return { return {
props: { props: {
urlId: ctx.query.urlId as string, urlId: ctx.query.urlId as string,
forceUserId: userId,
}, },
}; };
}, },

View file

@ -1,7 +1,10 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils";
import { z } from "zod"; import { z } from "zod";
import { sendNotification } from "../../../utils/api-utils"; import { sendNotification } from "../../../utils/api-utils";
import { createToken } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc"; import { publicProcedure, router } from "../../trpc";
export const participants = router({ export const participants = router({
@ -46,7 +49,8 @@ export const participants = router({
.input( .input(
z.object({ z.object({
pollId: z.string(), pollId: z.string(),
name: z.string().nonempty("Participant name is required"), name: z.string().min(1, "Participant name is required"),
email: z.string().optional(),
votes: z votes: z
.object({ .object({
optionId: z.string(), optionId: z.string(),
@ -55,9 +59,9 @@ export const participants = router({
.array(), .array(),
}), }),
) )
.mutation(async ({ ctx, input: { pollId, votes, name } }) => { .mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
const user = ctx.session.user; const user = ctx.session.user;
const participant = await prisma.participant.create({ const res = await prisma.participant.create({
data: { data: {
pollId: pollId, pollId: pollId,
name: name, name: name,
@ -72,14 +76,43 @@ export const participants = router({
}, },
}, },
}, },
include: { select: {
votes: true, id: true,
poll: {
select: {
title: true,
participantUrlId: true,
},
},
}, },
}); });
const { poll, ...participant } = res;
if (email) {
const token = await createToken(
{ userId: user.id },
{
ttl: 0, // basically forever
},
);
await sendEmail("NewParticipantConfirmationEmail", {
to: email,
subject: `Your response for ${poll.title} has been received`,
props: {
name,
title: poll.title,
editSubmissionUrl: absoluteUrl(
`/p/${poll.participantUrlId}?token=${token}`,
),
},
});
}
await sendNotification(pollId, { await sendNotification(pollId, {
type: "newParticipant", type: "newParticipant",
participantName: participant.name, participantName: name,
}); });
return participant; return participant;

View file

@ -143,10 +143,13 @@ export const decryptToken = async <P extends Record<string, unknown>>(
export const createToken = async <T extends Record<string, unknown>>( export const createToken = async <T extends Record<string, unknown>>(
payload: T, payload: T,
options?: {
ttl?: number;
},
) => { ) => {
return await sealData(payload, { return await sealData(payload, {
password: sessionOptions.password, password: sessionOptions.password,
ttl: 60 * 15, // 15 minutes ttl: options?.ttl ?? 60 * 15, // 15 minutes
}); });
}; };

View file

@ -1,12 +1,11 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { load } from "cheerio"; import { load } from "cheerio";
import smtpTester from "smtp-tester"; import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.serial(() => { test.describe.serial(() => {
let mailServer: smtpTester.SmtpTester;
let pollUrl: string; let pollUrl: string;
let mailServer: SmtpTester;
test.beforeAll(async () => { test.beforeAll(async () => {
mailServer = smtpTester.init(4025); mailServer = smtpTester.init(4025);
}); });

View file

@ -1,18 +1,33 @@
import { expect, Page, Request, test } from "@playwright/test"; import { expect, Page, Request, test } from "@playwright/test";
import { load } from "cheerio";
import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.parallel(() => { test.describe.parallel(() => {
let page: Page; let adminPage: Page;
let touchRequest: Promise<Request>; let touchRequest: Promise<Request>;
let participantUrl: string;
let editSubmissionUrl: string;
let mailServer: SmtpTester;
test.beforeAll(async () => {
mailServer = smtpTester.init(4025);
});
test.afterAll(async () => {
mailServer.stop();
});
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
const context = await browser.newContext(); const context = await browser.newContext();
page = await context.newPage(); adminPage = await context.newPage();
touchRequest = page.waitForRequest( touchRequest = adminPage.waitForRequest(
(request) => (request) =>
request.method() === "POST" && request.method() === "POST" &&
request.url().includes("/api/trpc/polls.touch"), request.url().includes("/api/trpc/polls.touch"),
); );
await page.goto("/demo"); await adminPage.goto("/demo");
await page.waitForSelector('text="Lunch Meeting"'); await adminPage.waitForSelector('text="Lunch Meeting"');
}); });
test("should call touch endpoint", async () => { test("should call touch endpoint", async () => {
@ -21,30 +36,62 @@ test.describe.parallel(() => {
}); });
test("should be able to comment", async () => { test("should be able to comment", async () => {
await page.getByPlaceholder("Your name…").fill("Test user"); await adminPage.getByPlaceholder("Your name…").fill("Test user");
await page await adminPage
.getByPlaceholder("Leave a comment on this poll") .getByPlaceholder("Leave a comment on this poll")
.fill("This is a comment!"); .fill("This is a comment!");
await page.click("text='Comment'"); await adminPage.click("text='Comment'");
const comment = page.locator("data-testid=comment"); const comment = adminPage.locator("data-testid=comment");
await expect(comment.locator("text='This is a comment!'")).toBeVisible(); await expect(comment.locator("text='This is a comment!'")).toBeVisible();
await expect(comment.locator("text=You")).toBeVisible(); await expect(comment.locator("text=You")).toBeVisible();
}); });
test("should be able to vote", async () => { test("copy participant link", async () => {
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even await adminPage.click("text='Share'");
// when we only have a single option/checkbox. await adminPage.click("text='Copy link'");
await page.locator("data-testid=vote-selector >> nth=0").click(); participantUrl = await adminPage.evaluate("navigator.clipboard.readText()");
await page.locator("data-testid=vote-selector >> nth=2").click();
await page.click("button >> text='Continue'");
await page.type('[placeholder="Jessie Smith"]', "Test user"); expect(participantUrl).toMatch(/\/p\/[a-zA-Z0-9]+/);
await page.click("text='Submit'"); });
await expect(page.locator("text='Test user'")).toBeVisible(); test("should be able to vote with an email", async ({
await expect( page: participantPage,
page.locator("data-testid=participant-row >> nth=0").locator("text=You"), }) => {
).toBeVisible(); await participantPage.goto(participantUrl);
await participantPage.locator("data-testid=vote-selector >> nth=0").click();
await participantPage.locator("data-testid=vote-selector >> nth=2").click();
await participantPage.click("button >> text='Continue'");
await participantPage.type('[placeholder="Jessie Smith"]', "Anne");
await participantPage.type(
'[placeholder="jessie.smith@email.com"]',
"test@email.com",
);
await participantPage.click("text='Submit'");
await expect(participantPage.locator("text='Anne'")).toBeVisible();
const { email } = await mailServer.captureOne("test@email.com", {
wait: 5000,
});
expect(email.headers.subject).toBe(
"Your response for Lunch Meeting has been received",
);
const $ = load(email.html);
const href = $("#editSubmissionUrl").attr("href");
if (!href) {
throw new Error("Could not get edit submission link from email");
}
editSubmissionUrl = href;
});
test("should be able to edit submission", async ({ page: newPage }) => {
await newPage.goto(editSubmissionUrl);
await expect(newPage.getByTestId("participant-menu")).toBeVisible();
}); });
}); });

View file

@ -7,7 +7,8 @@
"build": "turbo build", "build": "turbo build",
"db:deploy": "turbo db:deploy", "db:deploy": "turbo db:deploy",
"db:generate": "turbo db:generate", "db:generate": "turbo db:generate",
"test": "turbo test", "db:migrate": "yarn workspace @rallly/database prisma migrate dev",
"test": "yarn workspace @rallly/web test",
"lint": "turbo lint", "lint": "turbo lint",
"lint:tsc": "turbo lint:tsc", "lint:tsc": "turbo lint:tsc",
"changelog:update": "gitmoji-changelog", "changelog:update": "gitmoji-changelog",

View file

@ -5,7 +5,8 @@
"scripts": { "scripts": {
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:push": "prisma db push --skip-generate", "db:push": "prisma db push --skip-generate",
"db:deploy": "prisma migrate deploy" "db:deploy": "prisma migrate deploy",
"db:migrate": "prisma migrate dev"
}, },
"main": "./index.ts", "main": "./index.ts",
"types": "./index.ts", "types": "./index.ts",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "participants" ADD COLUMN "email" TEXT;

View file

@ -62,6 +62,7 @@ model Poll {
model Participant { model Participant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
email String?
userId String? @map("user_id") userId String? @map("user_id")
poll Poll @relation(fields: [pollId], references: [id]) poll Poll @relation(fields: [pollId], references: [id])
pollId String @map("poll_id") pollId String @map("poll_id")

View file

@ -1,6 +1,7 @@
export * from "./templates/guest-verify-email"; export * from "./templates/guest-verify-email";
export * from "./templates/new-comment"; export * from "./templates/new-comment";
export * from "./templates/new-participant"; export * from "./templates/new-participant";
export * from "./templates/new-participant-confirmation";
export * from "./templates/new-poll"; export * from "./templates/new-poll";
export * from "./templates/new-poll-verification"; export * from "./templates/new-poll-verification";
export * from "./templates/verification-code"; export * from "./templates/verification-code";

View file

@ -0,0 +1,35 @@
import { EmailLayout } from "./components/email-layout";
import { Button, Section, Text } from "./components/styled-components";
interface NewParticipantConfirmationEmailProps {
name: string;
title: string;
editSubmissionUrl: string;
}
export const NewParticipantConfirmationEmail = ({
title = "Untitled Poll",
name = "Guest",
editSubmissionUrl = "https://rallly.co",
}: NewParticipantConfirmationEmailProps) => {
return (
<EmailLayout preview="To edit your response use the link below">
<Text>Hi {name},</Text>
<Text>
Thank you for submitting your availability for <strong>{title}</strong>.
</Text>
<Text>To review your response, use the link below:</Text>
<Section>
<Button id="editSubmissionUrl" href={editSubmissionUrl}>
Review response &rarr;
</Button>
</Section>
<Text>
<em className="text-slate-500">
Keep this link safe and do not share it with others.
</em>
</Text>
</EmailLayout>
);
};
export default NewParticipantConfirmationEmail;

View file

@ -17,9 +17,7 @@ export const NewParticipantEmail = ({
unsubscribeUrl = "https://rallly.co", unsubscribeUrl = "https://rallly.co",
}: NewParticipantEmailProps) => { }: NewParticipantEmailProps) => {
return ( return (
<EmailLayout <EmailLayout preview={`${participantName} has responded`}>
preview={`${participantName} has shared their availability for ${title}`}
>
<Text>Hi {name},</Text> <Text>Hi {name},</Text>
<Text> <Text>
<strong>{participantName}</strong> has shared their availability for{" "} <strong>{participantName}</strong> has shared their availability for{" "}

View file

@ -1,7 +1,7 @@
import { Heading } from "@react-email/heading"; import { Heading } from "@react-email/heading";
import { EmailLayout } from "./components/email-layout"; import { EmailLayout } from "./components/email-layout";
import { Text } from "./components/styled-components"; import { Section, Text } from "./components/styled-components";
interface VerificationCodeEmailProps { interface VerificationCodeEmailProps {
name: string; name: string;
@ -13,18 +13,20 @@ export const VerificationCodeEmail = ({
code = "123456", code = "123456",
}: VerificationCodeEmailProps) => { }: VerificationCodeEmailProps) => {
return ( return (
<EmailLayout preview="Here is your 6-digit code"> <EmailLayout preview={`Your 6-digit code is ${code}`}>
<Text>Hi {name},</Text> <Text>Hi {name},</Text>
<Text>Your 6-digit code is:</Text> <Text>Please use the code below to verify your email address.</Text>
<Heading className="font-sans tracking-widest" id="code"> <Section className="rounded bg-gray-50 text-center">
{code} <Text>Your 6-digit code is:</Text>
</Heading> <Heading className="font-sans tracking-widest" id="code">
<Text> {code}
<span className="text-slate-500"> </Heading>
This code is valid for 10 minutes <Text>
</span> <span className="text-slate-500">
</Text> This code is valid for 15 minutes
<Text>Use this code to complete the verification process.</Text> </span>
</Text>
</Section>
</EmailLayout> </EmailLayout>
); );
}; };