mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-06 04:31:50 +02:00
✨ Allow participant to enter email to receive edit link (#534)
This commit is contained in:
parent
aab999598e
commit
0ac3c95755
20 changed files with 230 additions and 81 deletions
|
@ -16,6 +16,7 @@ const config: PlaywrightTestConfig = {
|
|||
use: {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
baseURL,
|
||||
permissions: ["clipboard-read"],
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,23 +32,25 @@ const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
|
|||
<title>{poll.title}</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</Head>
|
||||
<ParticipantsProvider pollId={poll.id}>
|
||||
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
|
||||
<ModalProvider>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{user.id === poll.user.id ? (
|
||||
<Link
|
||||
className="btn-default"
|
||||
href={`/admin/${poll.adminUrlId}`}
|
||||
>
|
||||
← {t("goToAdmin")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Poll />
|
||||
</div>
|
||||
</ModalProvider>
|
||||
</PollContextProvider>
|
||||
</ParticipantsProvider>
|
||||
<UserProvider forceUserId={forceUserId ?? undefined}>
|
||||
<ParticipantsProvider pollId={poll.id}>
|
||||
<PollContextProvider poll={poll} urlId={urlId} admin={false}>
|
||||
<ModalProvider>
|
||||
<div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
|
||||
{user.id === poll.user.id ? (
|
||||
<Link
|
||||
className="btn-default"
|
||||
href={`/admin/${poll.adminUrlId}`}
|
||||
>
|
||||
← {t("goToAdmin")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Poll />
|
||||
</div>
|
||||
</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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "participants" ADD COLUMN "email" TEXT;
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 →
|
||||
</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;
|
|
@ -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{" "}
|
||||
|
|
|
@ -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>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
|
||||
</span>
|
||||
</Text>
|
||||
<Text>Use this code to complete the verification process.</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 15 minutes
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue