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: {
viewport: { width: 1280, height: 720 },
baseURL,
permissions: ["clipboard-read"],
trace: "retain-on-failure",
},
webServer: {

View file

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

View file

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

View file

@ -69,7 +69,10 @@ export const ParticipantRowView: React.FunctionComponent<{
<Dropdown
placement="bottom-start"
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" />
</button>
}

View file

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

View file

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

View file

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

View file

@ -6,8 +6,8 @@ import { useTranslation } from "next-i18next";
import { ParticipantsProvider } from "@/components/participants-provider";
import { Poll } from "@/components/poll";
import { PollContextProvider } from "@/components/poll-context";
import { useUser } from "@/components/user-provider";
import { withSessionSsr } from "@/utils/auth";
import { UserProvider, useUser } from "@/components/user-provider";
import { decryptToken, withSessionSsr } from "@/utils/auth";
import { trpc } from "@/utils/trpc";
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 { 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 { user } = useUser();
@ -29,10 +32,11 @@ const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
<title>{poll.title}</title>
<meta name="robots" content="noindex,nofollow" />
</Head>
<UserProvider forceUserId={forceUserId ?? undefined}>
<ParticipantsProvider pollId={poll.id}>
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
<ModalProvider>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
{user.id === poll.user.id ? (
<Link
className="btn-default"
@ -46,6 +50,7 @@ const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
</ModalProvider>
</PollContextProvider>
</ParticipantsProvider>
</UserProvider>
</>
);
}
@ -61,9 +66,17 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
[
withPageTranslations(["common", "app", "errors"]),
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 {
props: {
urlId: ctx.query.urlId as string,
forceUserId: userId,
},
};
},

View file

@ -1,7 +1,10 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils";
import { z } from "zod";
import { sendNotification } from "../../../utils/api-utils";
import { createToken } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc";
export const participants = router({
@ -46,7 +49,8 @@ export const participants = router({
.input(
z.object({
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
.object({
optionId: z.string(),
@ -55,9 +59,9 @@ export const participants = router({
.array(),
}),
)
.mutation(async ({ ctx, input: { pollId, votes, name } }) => {
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
const user = ctx.session.user;
const participant = await prisma.participant.create({
const res = await prisma.participant.create({
data: {
pollId: pollId,
name: name,
@ -72,14 +76,43 @@ export const participants = router({
},
},
},
include: {
votes: true,
select: {
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, {
type: "newParticipant",
participantName: participant.name,
participantName: name,
});
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>>(
payload: T,
options?: {
ttl?: number;
},
) => {
return await sealData(payload, {
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 { load } from "cheerio";
import smtpTester from "smtp-tester";
import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.serial(() => {
let mailServer: smtpTester.SmtpTester;
let pollUrl: string;
let mailServer: SmtpTester;
test.beforeAll(async () => {
mailServer = smtpTester.init(4025);
});

View file

@ -1,18 +1,33 @@
import { expect, Page, Request, test } from "@playwright/test";
import { load } from "cheerio";
import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.parallel(() => {
let page: Page;
let adminPage: Page;
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 }) => {
const context = await browser.newContext();
page = await context.newPage();
touchRequest = page.waitForRequest(
adminPage = await context.newPage();
touchRequest = adminPage.waitForRequest(
(request) =>
request.method() === "POST" &&
request.url().includes("/api/trpc/polls.touch"),
);
await page.goto("/demo");
await page.waitForSelector('text="Lunch Meeting"');
await adminPage.goto("/demo");
await adminPage.waitForSelector('text="Lunch Meeting"');
});
test("should call touch endpoint", async () => {
@ -21,30 +36,62 @@ test.describe.parallel(() => {
});
test("should be able to comment", async () => {
await page.getByPlaceholder("Your name…").fill("Test user");
await page
await adminPage.getByPlaceholder("Your name…").fill("Test user");
await adminPage
.getByPlaceholder("Leave a comment on this poll")
.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=You")).toBeVisible();
});
test("should be able to vote", async () => {
// There is a hidden checkbox (nth=0) that exists so that the behaviour of the form is consistent even
// when we only have a single option/checkbox.
await page.locator("data-testid=vote-selector >> nth=0").click();
await page.locator("data-testid=vote-selector >> nth=2").click();
await page.click("button >> text='Continue'");
test("copy participant link", async () => {
await adminPage.click("text='Share'");
await adminPage.click("text='Copy link'");
participantUrl = await adminPage.evaluate("navigator.clipboard.readText()");
await page.type('[placeholder="Jessie Smith"]', "Test user");
await page.click("text='Submit'");
expect(participantUrl).toMatch(/\/p\/[a-zA-Z0-9]+/);
});
await expect(page.locator("text='Test user'")).toBeVisible();
await expect(
page.locator("data-testid=participant-row >> nth=0").locator("text=You"),
).toBeVisible();
test("should be able to vote with an email", async ({
page: participantPage,
}) => {
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",
"db:deploy": "turbo db:deploy",
"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:tsc": "turbo lint:tsc",
"changelog:update": "gitmoji-changelog",

View file

@ -5,7 +5,8 @@
"scripts": {
"db:generate": "prisma 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",
"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 {
id String @id @default(cuid())
name String
email String?
userId String? @map("user_id")
poll Poll @relation(fields: [pollId], references: [id])
pollId String @map("poll_id")

View file

@ -1,6 +1,7 @@
export * from "./templates/guest-verify-email";
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";

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",
}: NewParticipantEmailProps) => {
return (
<EmailLayout
preview={`${participantName} has shared their availability for ${title}`}
>
<EmailLayout preview={`${participantName} has responded`}>
<Text>Hi {name},</Text>
<Text>
<strong>{participantName}</strong> has shared their availability for{" "}

View file

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