Skip user details step for logged in users (#602)

This commit is contained in:
Luke Vella 2023-03-23 12:17:56 +00:00 committed by GitHub
parent f858bcc5f4
commit d8e3dcd357
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 548 additions and 636 deletions

View file

@ -14,6 +14,8 @@
"simple-import-sort/exports": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error"
"import/no-duplicates": "error",
"@typescript-eslint/no-unused-vars": "error",
"no-console": ["error", { "allow": ["warn", "error", "info"] }]
}
}

View file

@ -1,11 +1,9 @@
{
"12h": "12-hour",
"24h": "24-hour",
"addParticipant": "Add participant",
"addTimeOption": "Add time option",
"adminPollTitle": "{{title}}: Admin",
"alreadyRegistered": "Already registered? <a>Login →</a>",
"alreadyVoted": "You have already voted",
"applyToAllDates": "Apply to all dates",
"areYouSure": "Are you sure?",
"back": "Back",
@ -15,15 +13,10 @@
"changeName": "Change name",
"changeNameDescription": "Enter a new name for this participant.",
"changeNameInfo": "This will not affect any votes you have already made.",
"close": "Close",
"comment": "Comment",
"commentPlaceholder": "Leave a comment on this poll (visible to everyone)",
"comments": "Comments",
"feedbackSent": "Thank you! Your feedback has been sent.",
"feedbackFormLabel": "How can we improve <appname />?",
"feedbackFormPlaceholder": "Share your thoughts…",
"feedbackFormFooter": "Need help? Visit the <a>support page</a>.",
"feedbackFormTitle": "Feedback Form",
"close": "Close",
"continue": "Continue",
"copied": "Copied",
"copyLink": "Copy link",
@ -45,7 +38,6 @@
"demoPollNotice": "Demo polls are automatically deleted after 1 day",
"description": "Description",
"descriptionPlaceholder": "Hey everyone, please choose the dates that work for you!",
"donate": "Donate",
"edit": "Edit",
"editDetails": "Edit details",
"editOptions": "Edit options",
@ -57,6 +49,11 @@
"endSession": "End session",
"expiredOrInvalidLink": "This link is expired or invalid. Please request a new link.",
"exportToCsv": "Export to CSV",
"feedbackFormFooter": "Need help? Visit the <a>support page</a>.",
"feedbackFormLabel": "How can we improve <appname />?",
"feedbackFormPlaceholder": "Share your thoughts…",
"feedbackFormTitle": "Feedback Form",
"feedbackSent": "Thank you! Your feedback has been sent.",
"forgetMe": "Forget me",
"goToAdmin": "Go to Admin",
"guest": "Guest",
@ -74,7 +71,6 @@
"loginSuccessful": "You're logged in! Please wait while you are redirected…",
"logout": "Logout",
"manage": "Manage",
"menu": "Menu",
"mixedOptionsDescription": "You can't have both time and date options in the same poll. Which would you like to keep?",
"mixedOptionsKeepDates": "Keep date options",
"mixedOptionsKeepTimes": "Keep time options",
@ -91,12 +87,10 @@
"nextMonth": "Next month",
"no": "No",
"noDatesSelected": "No dates selected",
"notificationsDisabled": "Notifications have been disabled",
"notificationsEnabled": "Notifications have been enabled for <b>{{title}}</b>",
"notificationsOff": "Get notified when participants respond to your poll",
"notificationsDisabled": "Notifications have been disabled for <b>{{title}}</b>",
"notificationsGuest": "Log in to turn on notifications",
"notificationsOff": "Notifications are off",
"notificationsOn": "Notifications are on",
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
"notificationsVerifyEmail": "You need to verify your email to turn on notifications",
"notRegistered": "Create a new account →",
"noVotes": "No one has voted for this option",
"ok": "Ok",
@ -115,7 +109,6 @@
"participantCount_two": "{{count}} participants",
"participantCount_zero": "{{count}} participants",
"pollHasBeenLocked": "This poll has been locked",
"pollOwnerNotice": "Hey {{name}}, looks like you are the owner of this poll.",
"pollsEmpty": "No polls created",
"possibleAnswers": "Possible answers",
"preferences": "Preferences",
@ -145,7 +138,6 @@
"titlePlaceholder": "Monthly Meetup",
"today": "Today",
"unlockPoll": "Unlock poll",
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
"user": "User",
"userAlreadyExists": "A user with that email already exists",
"userDoesNotExist": "The requested user was not found",
@ -154,7 +146,6 @@
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
"verificationCodePlaceholder": "Enter your 6-digit code",
"verificationCodeSent": "A verification code has been sent to <b>{{email}}</b> <a>Change</a>",
"verificationEmailSent": "An email has been sent to <b>{{email}}</b> with a link to enable notifications",
"verifyYourEmail": "Verify your email",
"weekStartsOn": "Week starts on",
"weekView": "Week view",

View file

@ -1,7 +1,6 @@
{
"blog": "Blog",
"discussions": "Discussions",
"donate": "Donate",
"footerCredit": "Made by <a>@imlukevella</a>",
"footerSponsor": "This project is user-funded. Please consider supporting it by <a>donating</a>.",
"home": "Home",

View file

@ -1,46 +1,18 @@
import { AnimatePresence, m } from "framer-motion";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import React from "react";
import toast from "react-hot-toast";
import { Button } from "@/components/button";
import Share from "@/components/icons/share.svg";
import { usePostHog } from "@/utils/posthog";
import { useParticipants } from "./participants-provider";
import ManagePoll from "./poll/manage-poll";
import { useUpdatePollMutation } from "./poll/mutations";
import NotificationsToggle from "./poll/notifications-toggle";
import { usePoll } from "./poll-context";
import Sharing from "./sharing";
export const AdminControls = (props: { children?: React.ReactNode }) => {
const { urlId } = usePoll();
const { t } = useTranslation("app");
const router = useRouter();
const { mutate: updatePollMutation } = useUpdatePollMutation();
const posthog = usePostHog();
React.useEffect(() => {
if (router.query.unsubscribe) {
updatePollMutation(
{ urlId: urlId, notifications: false },
{
onSuccess: () => {
toast.success(t("notificationsDisabled"));
posthog?.capture("unsubscribed from notifications");
},
},
);
router.replace(`/admin/${router.query.urlId}`, undefined, {
shallow: true,
});
}
}, [urlId, router, updatePollMutation, t, posthog]);
const { participants } = useParticipants();
const [isSharingVisible, setIsSharingVisible] = React.useState(

View file

@ -13,7 +13,10 @@ export const LoginModal: React.FunctionComponent<{
const [defaultEmail, setDefaultEmail] = React.useState("");
return (
<div className="w-[420px] max-w-full overflow-hidden bg-white shadow-sm">
<div
data-testid="login-modal"
className="w-[420px] max-w-full overflow-hidden bg-white shadow-sm"
>
<div className="bg-pattern border-t-primary-500 border-b border-t-4 bg-slate-500/5 p-4 text-center sm:p-8">
<Logo className="text-2xl" />
</div>

View file

@ -1,7 +1,6 @@
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import React from "react";
import { useSessionStorage } from "react-use";
import { usePostHog } from "@/utils/posthog";
@ -22,8 +21,6 @@ import { useUser } from "./user-provider";
type StepName = "eventDetails" | "options" | "userDetails";
const steps: StepName[] = ["eventDetails", "options", "userDetails"];
const required = <T,>(v: T | undefined): T => {
if (!v) {
throw new Error("Required value is missing");
@ -32,9 +29,6 @@ const required = <T,>(v: T | undefined): T => {
return v;
};
const initialNewEventData: NewEventData = { currentStep: 0 };
const sessionStorageKey = "newEventFormData";
export interface CreatePollPageProps {
title?: string;
location?: string;
@ -42,47 +36,34 @@ export interface CreatePollPageProps {
view?: "week" | "month";
}
const Page: React.FunctionComponent<CreatePollPageProps> = ({
title,
location,
description,
view,
}) => {
const Page: React.FunctionComponent = () => {
const { t } = useTranslation("app");
const router = useRouter();
const session = useUser();
const [persistedFormData, setPersistedFormData] =
useSessionStorage<NewEventData>(sessionStorageKey, {
const steps: StepName[] = React.useMemo(
() =>
session.user.isGuest
? ["eventDetails", "options", "userDetails"]
: ["eventDetails", "options"],
[session.user.isGuest],
);
const [formData, setFormData] = React.useState<NewEventData>({
currentStep: 0,
eventDetails: {
title,
location,
description,
},
options: {
view,
},
userDetails:
session.user?.isGuest === false
? {
name: session.user.name,
contact: session.user.email,
}
: undefined,
});
const [formData, setTransientFormData] = React.useState(persistedFormData);
const setFormData = React.useCallback(
(newEventData: NewEventData) => {
setTransientFormData(newEventData);
setPersistedFormData(newEventData);
},
[setPersistedFormData],
);
React.useEffect(() => {
const newStep = Math.min(steps.length - 1, formData.currentStep);
if (newStep !== formData.currentStep) {
setFormData((prevData) => ({
...prevData,
currentStep: newStep,
}));
}
}, [formData.currentStep, steps.length]);
const currentStepIndex = formData?.currentStep ?? 0;
@ -100,7 +81,6 @@ const Page: React.FunctionComponent<CreatePollPageProps> = ({
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
});
setPersistedFormData(initialNewEventData);
router.replace(`/admin/${res.urlId}`);
},
});
@ -125,10 +105,12 @@ const Page: React.FunctionComponent<CreatePollPageProps> = ({
type: "date",
location: formData?.eventDetails?.location,
description: formData?.eventDetails?.description,
user: {
user: session.user.isGuest
? {
name: required(formData?.userDetails?.name),
email: required(formData?.userDetails?.contact),
},
}
: undefined,
timeZone: formData?.options?.timeZone,
options: required(formData?.options?.options).map(encodeDateOption),
});
@ -197,7 +179,7 @@ const Page: React.FunctionComponent<CreatePollPageProps> = ({
disabled={isBusy}
onClick={() => {
setFormData({
...persistedFormData,
...formData,
currentStep: currentStepIndex - 1,
});
}}

View file

@ -42,15 +42,8 @@ const Discussion: React.FunctionComponent = () => {
const posthog = usePostHog();
const addComment = trpc.polls.comments.add.useMutation({
onSuccess: (newComment) => {
onSuccess: () => {
posthog?.capture("created comment");
queryClient.polls.comments.list.setData(
{ pollId },
(existingComments = []) => {
return [...existingComments, newComment];
},
);
},
});

View file

@ -7,7 +7,6 @@ import * as React from "react";
import ChevronDown from "@/components/icons/chevron-down.svg";
import { useParticipants } from "../../participants-provider";
import { usePoll } from "../../poll-context";
import { ConnectedScoreSummary } from "../score-summary";
import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon";
@ -181,37 +180,13 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
onChange,
participants,
editable = false,
yesScore,
optionId,
}) => {
const { getVote } = usePoll();
const showVotes = !!(selectedParticipantId || editable);
const [expanded, setExpanded] = React.useState(false);
const selectorRef = React.useRef<HTMLButtonElement>(null);
const [active, setActive] = React.useState(false);
const score = React.useMemo(() => {
if (!editable) {
return yesScore;
}
if (selectedParticipantId) {
const currentVote = getVote(selectedParticipantId, optionId);
return (
yesScore +
(currentVote === "yes"
? vote === "yes"
? 0
: -1
: vote === "yes"
? 1
: 0)
);
}
return yesScore + (vote === "yes" ? 1 : 0);
}, [editable, getVote, optionId, selectedParticipantId, vote, yesScore]);
return (
<div
className={clsx("space-y-4 overflow-hidden p-3", {

View file

@ -79,10 +79,10 @@ export const useUpdatePollMutation = () => {
const queryClient = trpc.useContext();
const posthog = usePostHog();
return trpc.polls.update.useMutation({
onSuccess: (data) => {
onSuccess: (_data, { urlId }) => {
queryClient.polls.invalidate();
posthog?.capture("updated poll", {
id: data.id,
id: urlId,
});
},
});

View file

@ -1,95 +1,73 @@
import { Trans, useTranslation } from "next-i18next";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useLoginModal } from "@/components/auth/login-modal";
import { Button } from "@/components/button";
import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.svg";
import { useUser } from "@/components/user-provider";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc";
import { usePollByAdmin } from "@/utils/trpc/hooks";
import { usePoll } from "../poll-context";
import Tooltip from "../tooltip";
import { useUpdatePollMutation } from "./mutations";
const Email = (props: { children?: React.ReactNode }) => {
return (
<span className="text-primary-300 whitespace-nowrap font-mono font-medium">
{props.children}
</span>
);
};
const NotificationsToggle: React.FunctionComponent = () => {
const { poll, urlId } = usePoll();
const { poll } = usePoll();
const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation();
const requestEnableNotifications =
trpc.polls.enableNotifications.useMutation();
const { data } = usePollByAdmin();
const watchers = data.watchers ?? [];
const { user } = useUser();
const isWatching = watchers.some(({ userId }) => userId === user.id);
const posthog = usePostHog();
const watch = trpc.polls.watch.useMutation({
onSuccess: () => {
posthog?.capture("turned notifications on");
},
});
const unwatch = trpc.polls.unwatch.useMutation({
onSuccess: () => {
posthog?.capture("turned notifications off");
},
});
const isUpdating = watch.isLoading || unwatch.isLoading;
const { openLoginModal } = useLoginModal();
return (
<Tooltip
content={
<div className="max-w-md">
{requestEnableNotifications.isSuccess ? (
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{
email: poll.user.email,
}}
components={{ b: <Email /> }}
/>
) : poll.notifications ? (
<div>
<div className="text-primary-300 font-medium">
{t("notificationsOn")}
</div>
<div className="max-w-sm">
<Trans
t={t}
i18nKey="notificationsOnDescription"
values={{
email: poll.user.email,
}}
components={{
b: <Email />,
}}
/>
</div>
</div>
) : (
t("notificationsOff")
)}
{user.isGuest
? t("notificationsGuest")
: isWatching
? t("notificationsOn")
: t("notificationsOff")}
</div>
}
>
<Button
data-testid="notifications-toggle"
loading={
isUpdatingNotifications || requestEnableNotifications.isLoading
}
icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
disabled={requestEnableNotifications.isSuccess}
loading={isUpdating}
disabled={poll.demo}
icon={isWatching ? <Bell /> : <BellCrossed />}
onClick={async () => {
if (!poll.verified) {
await requestEnableNotifications.mutateAsync({
adminUrlId: poll.adminUrlId,
});
if (user.isGuest) {
// ask to log in
openLoginModal();
} else {
setIsUpdatingNotifications(true);
updatePollMutation(
{
urlId,
notifications: !poll.notifications,
},
{
onSuccess: () => {
setIsUpdatingNotifications(false);
},
},
);
// toggle
if (isWatching) {
await unwatch.mutateAsync({ pollId: poll.id });
} else {
await watch.mutateAsync({ pollId: poll.id });
}
}
}}
/>

View file

@ -17,7 +17,7 @@ const PollSubheader: React.FunctionComponent = () => {
i18nKey="createdBy"
t={t}
values={{
name: poll.authorName,
name: poll.user?.name ?? t("guest"),
}}
components={{
b: <span />,

View file

@ -9,7 +9,6 @@ import { requiredString, validEmail } from "../../utils/form-validation";
import { trpc } from "../../utils/trpc";
import { Button } from "../button";
import { TextInput } from "../text-input";
import { useUser } from "../user-provider";
export interface UserDetailsProps {
userId: string;

View file

@ -105,7 +105,7 @@ const Tooltip: React.FunctionComponent<TooltipProps> = ({
<AnimatePresence>
{open ? (
<m.div
className="z-30 rounded-md bg-slate-700 px-3 py-2 text-slate-200 shadow-md"
className="z-30 rounded-md bg-slate-700 px-2 py-1 text-slate-100 shadow-md"
initial="hidden"
transition={{
duration: 0.1,

View file

@ -8,7 +8,7 @@ import { ParticipantsProvider } from "@/components/participants-provider";
import { Poll } from "@/components/poll";
import { PollContextProvider } from "@/components/poll-context";
import { withAuthIfRequired, withSessionSsr } from "@/utils/auth";
import { trpc } from "@/utils/trpc";
import { usePollByAdmin } from "@/utils/trpc/hooks";
import { withPageTranslations } from "@/utils/with-page-translations";
import { AdminControls } from "../../components/admin-control";
@ -18,7 +18,7 @@ import { NextPageWithLayout } from "../../types";
const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
const { t } = useTranslation("app");
const pollQuery = trpc.polls.getByAdminUrlId.useQuery({ urlId });
const pollQuery = usePollByAdmin();
const poll = pollQuery.data;

View file

@ -121,6 +121,15 @@ export default async function handler(
},
});
// Delete watchers
await prisma.watcher.deleteMany({
where: {
pollId: {
in: pollIdsToDelete,
},
},
});
// Delete polls
// Using execute raw to bypass soft delete middelware
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join(

View file

@ -0,0 +1,158 @@
import { prisma } from "@rallly/database";
import clsx from "clsx";
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-crossed.svg";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { Spinner } from "@/components/spinner";
import {
composeGetServerSideProps,
decryptToken,
DisableNotificationsPayload,
withSessionSsr,
} from "@/utils/auth";
import { withPageTranslations } from "@/utils/with-page-translations";
const Redirect = (props: React.PropsWithChildren<{ redirect: string }>) => {
const router = useRouter();
const [enabled, setEnabled] = React.useState(false);
const { t } = useTranslation("app");
React.useEffect(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
setTimeout(() => {
router.replace(props.redirect);
}, 3000);
}, [router, props.redirect]);
return (
<div>
<div className="flex h-8 items-center justify-center gap-4">
{enabled ? (
<Bell
className={clsx("animate-popIn h-5", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-slate-800">{props.children}</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={props.redirect} />,
}}
/>
</div>
</div>
);
};
type Data = { title: undefined; adminUrlId: undefined };
type PageProps =
| {
error: "pollNotFound" | "invalidToken";
data: undefined;
}
| { error: undefined; data: Data };
const Page = (props: PageProps) => {
const { t } = useTranslation("app");
return (
<AuthLayout title={t("loading")}>
{props.error !== undefined ? (
<div>{props.error}</div>
) : (
<Redirect redirect={`/admin/${props.data.adminUrlId}`}>
<Trans
t={t}
i18nKey="notificationsDisabled"
values={{ title: props.data.title }}
components={{ b: <strong /> }}
/>
</Redirect>
)}
</AuthLayout>
);
};
export const getServerSideProps = composeGetServerSideProps(
withPageTranslations(["app"]),
withSessionSsr(async (ctx) => {
const token = ctx.query.token as string;
const payload = await decryptToken<DisableNotificationsPayload>(token);
if (!payload) {
return {
props: {
errorCode: "invalidToken",
},
};
}
const watcher = await prisma.watcher.findUnique({
select: {
id: true,
poll: {
select: {
adminUrlId: true,
title: true,
},
},
},
where: {
id: payload.watcherId,
},
});
if (watcher) {
await prisma.watcher.delete({
where: {
id: watcher.id,
},
});
return {
props: {
data: {
title: watcher.poll.title,
adminUrlId: watcher.poll.adminUrlId,
},
},
};
} else {
const poll = await prisma.poll.findFirst({
where: { id: payload.pollId },
select: { adminUrlId: true, title: true },
});
if (!poll) {
return {
props: {
errorCode: "pollNotFound",
},
};
}
return {
props: {
data: { adminUrlId: poll.adminUrlId, title: poll.title },
},
};
}
}),
);
export default Page;

View file

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

View file

@ -37,7 +37,7 @@ const Page: NextPageWithLayout<{
<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 ? (
{user.id === poll.userId ? (
<Link
className="btn-default"
href={`/admin/${poll.adminUrlId}`}

View file

@ -3,11 +3,8 @@ 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 { nanoid } from "../../utils/nanoid";
import { GetPollApiResponse } from "../../utils/trpc/types";
import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
import { comments } from "./polls/comments";
import { demo } from "./polls/demo";
@ -17,17 +14,14 @@ const defaultSelectFields: {
id: true;
timeZone: true;
title: true;
authorName: true;
location: true;
description: true;
createdAt: true;
adminUrlId: true;
participantUrlId: true;
verified: true;
closed: true;
legacy: true;
demo: true;
notifications: true;
options: {
orderBy: {
value: "asc";
@ -39,16 +33,13 @@ const defaultSelectFields: {
id: true,
timeZone: true,
title: true,
authorName: true,
location: true,
description: true,
createdAt: true,
adminUrlId: true,
participantUrlId: true,
verified: true,
closed: true,
legacy: true,
notifications: true,
demo: true,
options: {
orderBy: {
@ -85,10 +76,12 @@ export const polls = router({
timeZone: z.string().optional(),
location: z.string().optional(),
description: z.string().optional(),
user: z.object({
user: z
.object({
name: z.string(),
email: z.string(),
}),
})
.optional(),
options: z.string().array(),
demo: z.boolean().optional(),
}),
@ -97,17 +90,25 @@ export const polls = router({
async ({ ctx, input }): Promise<{ id: string; urlId: string }> => {
const adminUrlId = await nanoid();
const participantUrlId = await nanoid();
let verified = false;
if (ctx.session.user.isGuest === false) {
let email = input.user?.email;
let name = input.user?.name;
if (!ctx.user.isGuest) {
const user = await prisma.user.findUnique({
where: { id: ctx.session.user.id },
select: { email: true, name: true },
where: { id: ctx.user.id },
});
// If user is logged in with the same email address
if (user?.email === input.user.email) {
verified = true;
if (!user) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User not found",
});
}
email = user.email;
name = user.name;
}
const poll = await prisma.poll.create({
@ -123,23 +124,18 @@ export const polls = router({
timeZone: input.timeZone,
location: input.location,
description: input.description,
authorName: input.user.name,
authorName: input.user?.name,
demo: input.demo,
verified: verified,
notifications: verified,
adminUrlId,
participantUrlId,
user: {
connectOrCreate: {
where: {
email: input.user.email,
},
userId: ctx.user.id,
watchers: !ctx.user.isGuest
? {
create: {
id: await nanoid(),
...input.user,
},
},
userId: ctx.user.id,
},
}
: undefined,
options: {
createMany: {
data: input.options.map((value) => ({
@ -153,16 +149,18 @@ export const polls = router({
const adminLink = absoluteUrl(`/admin/${adminUrlId}`);
const participantLink = absoluteUrl(`/p/${participantUrlId}`);
if (email && name) {
await sendEmail("NewPollEmail", {
to: input.user.email,
to: email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name: input.user.name,
name,
adminLink,
participantLink,
},
});
}
return { id: poll.id, urlId: adminUrlId };
},
@ -177,11 +175,10 @@ export const polls = router({
description: z.string().optional(),
optionsToDelete: z.string().array().optional(),
optionsToAdd: z.string().array().optional(),
notifications: z.boolean().optional(),
closed: z.boolean().optional(),
}),
)
.mutation(async ({ input }): Promise<GetPollApiResponse> => {
.mutation(async ({ input }) => {
const pollId = await getPollIdFromAdminUrlId(input.urlId);
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
@ -204,7 +201,7 @@ export const polls = router({
});
}
const poll = await prisma.poll.update({
await prisma.poll.update({
select: defaultSelectFields,
where: {
id: pollId,
@ -214,12 +211,9 @@ export const polls = router({
location: input.location,
description: input.description,
timeZone: input.timeZone,
notifications: input.notifications,
closed: input.closed,
},
});
return { ...poll };
}),
delete: possiblyPublicProcedure
.input(
@ -251,43 +245,53 @@ export const polls = router({
participants,
comments,
// END LEGACY ROUTES
enableNotifications: possiblyPublicProcedure
.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,
},
watch: possiblyPublicProcedure
.input(z.object({ pollId: z.string() }))
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guests can't watch polls",
});
if (!poll) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const token = await createToken<EnableNotificationsTokenPayload>({
adminUrlId: input.adminUrlId,
await prisma.watcher.create({
data: {
pollId: input.pollId,
userId: ctx.user.id,
},
});
}),
unwatch: possiblyPublicProcedure
.input(z.object({ pollId: z.string() }))
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Guests can't unwatch polls",
});
}
const res = await prisma.watcher.findFirst({
where: {
pollId: input.pollId,
userId: ctx.user.id,
},
select: {
id: true,
},
});
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}`,
),
if (!res) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Not watching this poll",
});
}
await prisma.watcher.delete({
where: {
id: res.id,
},
});
}),
@ -299,7 +303,14 @@ export const polls = router({
)
.query(async ({ input }) => {
const res = await prisma.poll.findUnique({
select: defaultSelectFields,
select: {
...defaultSelectFields,
watchers: {
select: {
userId: true,
},
},
},
where: {
adminUrlId: input.urlId,
},
@ -323,7 +334,7 @@ export const polls = router({
)
.query(async ({ input, ctx }) => {
const res = await prisma.poll.findUnique({
select: defaultSelectFields,
select: { userId: true, ...defaultSelectFields },
where: {
participantUrlId: input.urlId,
},
@ -337,7 +348,7 @@ export const polls = router({
});
}
if (ctx.user.id === res.user.id) {
if (ctx.user.id === res.userId) {
return res;
} else {
return { ...res, adminUrlId: "" };

View file

@ -1,7 +1,10 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { z } from "zod";
import { sendNotification } from "../../../utils/api-utils";
import { absoluteUrl } from "@/utils/absolute-url";
import { createToken, DisableNotificationsPayload } from "@/utils/auth";
import { publicProcedure, router } from "../../trpc";
export const comments = router({
@ -39,13 +42,63 @@ export const comments = router({
authorName,
userId: user.id,
},
select: {
id: true,
createdAt: true,
authorName: true,
content: true,
poll: {
select: {
title: true,
adminUrlId: true,
},
},
},
});
await sendNotification(pollId, {
type: "newComment",
authorName: newComment.authorName,
const watchers = await prisma.watcher.findMany({
where: {
pollId,
},
select: {
id: true,
userId: true,
user: {
select: {
email: true,
name: true,
},
},
},
});
const poll = newComment.poll;
const emailsToSend: Promise<void>[] = [];
for (const watcher of watchers) {
const email = watcher.user.email;
const token = await createToken<DisableNotificationsPayload>(
{ watcherId: watcher.id, pollId },
{ ttl: 0 },
);
emailsToSend.push(
sendEmail("NewCommentEmail", {
to: email,
subject: `New comment on ${poll.title}`,
props: {
name: watcher.user.name,
authorName,
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),
title: poll.title,
},
}),
);
}
return newComment;
}),
delete: publicProcedure

View file

@ -78,7 +78,6 @@ export const demo = router({
location: "Starbucks, 901 New York Avenue",
description: `Hey everyone, please choose the dates when you are available to meet for our monthly get together. Looking forward to see you all!`,
authorName: "Johnny",
verified: true,
demo: true,
adminUrlId,
participantUrlId: await nanoid(),

View file

@ -1,10 +1,10 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { sendNotification } from "../../../utils/api-utils";
import { createToken } from "../../../utils/auth";
import { createToken, DisableNotificationsPayload } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc";
export const participants = router({
@ -61,7 +61,17 @@ export const participants = router({
)
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
const user = ctx.session.user;
const res = await prisma.participant.create({
const poll = await prisma.poll.findUnique({
where: { id: pollId },
select: { title: true, adminUrlId: true, participantUrlId: true },
});
if (!poll) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Poll not found" });
}
const participant = await prisma.participant.create({
data: {
pollId: pollId,
name: name,
@ -76,18 +86,8 @@ export const participants = router({
},
},
},
select: {
id: true,
poll: {
select: {
title: true,
participantUrlId: true,
},
},
},
});
const { poll, ...participant } = res;
const emailsToSend: Promise<void>[] = [];
if (email) {
const token = await createToken(
@ -112,12 +112,44 @@ export const participants = router({
);
}
const watchers = await prisma.watcher.findMany({
where: {
pollId,
},
select: {
id: true,
userId: true,
user: {
select: {
email: true,
name: true,
},
},
},
});
for (const watcher of watchers) {
const email = watcher.user.email;
const token = await createToken<DisableNotificationsPayload>(
{ watcherId: watcher.id, pollId },
{ ttl: 0 },
);
emailsToSend.push(
sendNotification(pollId, {
type: "newParticipant",
participantName: name,
sendEmail("NewParticipantEmail", {
to: email,
subject: `New response for ${poll.title}`,
props: {
name: watcher.user.name,
participantName: participant.name,
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),
title: poll.title,
},
}),
);
}
await Promise.all(emailsToSend);

View file

@ -30,7 +30,6 @@ export const user = router({
select: {
title: true,
closed: true,
verified: true,
createdAt: true,
adminUrlId: true,
},

View file

@ -1,87 +0,0 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "./absolute-url";
type NotificationAction =
| {
type: "newParticipant";
participantName: string;
}
| {
type: "newComment";
authorName: string;
};
export const sendNotification = async (
pollId: string,
action: NotificationAction,
): Promise<void> => {
try {
const poll = await prisma.poll.findUnique({
where: { id: pollId },
select: {
verified: true,
demo: true,
notifications: true,
adminUrlId: true,
title: true,
user: {
select: {
name: true,
email: true,
},
},
},
});
/**
* poll needs to:
* - exist
* - be verified
* - not be a demo
* - have notifications turned on
*/
if (
poll &&
poll?.user.email &&
poll.verified &&
!poll.demo &&
poll.notifications
) {
const homePageUrl = absoluteUrl();
const pollUrl = `${homePageUrl}/admin/${poll.adminUrlId}`;
const unsubscribeUrl = `${pollUrl}?unsubscribe=true`;
switch (action.type) {
case "newParticipant":
await sendEmail("NewParticipantEmail", {
to: poll.user.email,
subject: `Response received on ${poll.title}`,
props: {
name: poll.user.name,
participantName: action.participantName,
pollUrl,
unsubscribeUrl,
title: poll.title,
},
});
break;
case "newComment":
await sendEmail("NewCommentEmail", {
to: poll.user.email,
subject: `New comment on ${poll.title}`,
props: {
name: poll.user.name,
authorName: action.authorName,
pollUrl,
unsubscribeUrl,
title: poll.title,
},
});
break;
}
}
} catch (e) {
console.error(e);
}
};

View file

@ -35,10 +35,6 @@ export type LoginTokenPayload = {
code: string;
};
export type EnableNotificationsTokenPayload = {
adminUrlId: string;
};
export type RegisteredUserSession = {
isGuest: false;
id: string;
@ -51,6 +47,11 @@ export type GuestUserSession = {
id: string;
};
export type DisableNotificationsPayload = {
pollId: string;
watcherId: number;
};
export type UserSession = GuestUserSession | RegisteredUserSession;
const setUser = async (session: IronSession) => {

View file

@ -0,0 +1,15 @@
import { useRouter } from "next/router";
import { trpc } from "@/utils/trpc";
export const usePollByAdmin = () => {
const router = useRouter();
const adminUrlId = router.query.urlId as string;
const pollQuery = trpc.polls.getByAdminUrlId.useQuery({ urlId: adminUrlId });
if (!pollQuery.data) {
throw new Error("Poll not found");
}
return pollQuery;
};

View file

@ -3,19 +3,16 @@ import { Option, User } from "@rallly/database";
export type GetPollApiResponse = {
id: string;
title: string;
authorName: string;
location: string | null;
description: string | null;
options: Option[];
user: User;
user: User | null;
timeZone: string | null;
adminUrlId: string;
participantUrlId: string;
verified: boolean;
closed: boolean;
legacy: boolean;
demo: boolean;
notifications: boolean;
createdAt: Date;
deleted: boolean;
};

View file

@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test";
import { load } from "cheerio";
import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.serial(() => {
@ -60,34 +59,11 @@ test.describe.serial(() => {
pollUrl = page.url();
});
test("enable notifications", async ({ page, baseURL }) => {
test("notifications", async ({ page }) => {
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");
expect(verifyLink).toContain(baseURL);
if (!verifyLink) {
throw new Error("Could not get verification link from email");
}
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");
expect(page.getByTestId("login-modal")).toBeVisible();
});
// delete the poll we just created

View file

@ -6,7 +6,7 @@
"dev": "dotenv -- turbo dev",
"start": "turbo run start --filter=@rallly/web...",
"build": "turbo run build --filter=@rallly/web...",
"db:deploy": "turbo db:deploy",
"db:deploy": "prisma migrate deploy",
"db:generate": "turbo db:generate",
"db:migrate": "prisma migrate dev",
"test": "yarn workspace @rallly/web test",

View file

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "watchers" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "watchers_pkey" PRIMARY KEY ("id")
);
INSERT INTO watchers (poll_id, user_id)
SELECT id AS poll_id, user_id AS user_id
FROM polls
WHERE notifications = true;
-- AlterTable
ALTER TABLE "polls" DROP COLUMN "notifications",
ALTER COLUMN "author_name" DROP NOT NULL,
ALTER COLUMN "author_name" DROP DEFAULT,
DROP COLUMN "verified";
-- CreateIndex
CREATE INDEX "watchers_poll_id_idx" ON "watchers" USING HASH ("poll_id");
CREATE INDEX "watchers_user_id_idx" ON "watchers" USING HASH ("user_id");

View file

@ -17,6 +17,7 @@ model User {
updatedAt DateTime? @updatedAt @map("updated_at")
comments Comment[]
polls Poll[]
Watcher Watcher[]
@@map("users")
}
@ -36,19 +37,18 @@ model Poll {
type PollType
description String?
location String?
user User @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id])
userId String @map("user_id")
votes Vote[]
timeZone String? @map("time_zone")
verified Boolean @default(false)
options Option[]
participants Participant[]
authorName String @default("") @map("author_name")
watchers Watcher[]
authorName String? @map("author_name")
demo Boolean @default(false)
comments Comment[]
legacy Boolean @default(false)
closed Boolean @default(false)
notifications Boolean @default(false)
deleted Boolean @default(false)
deletedAt DateTime? @map("deleted_at")
touchedAt DateTime @default(now()) @map("touched_at")
@ -59,6 +59,19 @@ model Poll {
@@map("polls")
}
model Watcher {
id Int @id @default(autoincrement())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
pollId String @map("poll_id")
createdAt DateTime @default(now()) @map("created_at")
poll Poll @relation(fields: [pollId], references: [id])
@@index([userId], type: Hash)
@@index([pollId], type: Hash)
@@map("watchers")
}
model Participant {
id String @id @default(cuid())
name String
@ -116,7 +129,7 @@ model Comment {
poll Poll @relation(fields: [pollId], references: [id])
pollId String @map("poll_id")
authorName String @map("author_name")
user User? @relation(fields:[userId], references: [id])
user User? @relation(fields: [userId], references: [id])
userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")

View file

@ -3,7 +3,8 @@
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "email dev --port 3333 --dir ./src/templates"
"dev": "email dev --port 3333 --dir ./src/templates",
"lint": "eslint ./src"
},
"main": "./src/index.tsx",
"types": "./src/index.tsx",

View file

@ -4,4 +4,3 @@ export * from "./templates/new-participant";
export * from "./templates/new-participant-confirmation";
export * from "./templates/new-poll";
export * from "./templates/register";
export * from "./templates/turn-on-notifications";

View file

@ -13,7 +13,7 @@ import {
} from "@react-email/components";
import { Tailwind } from "@react-email/tailwind";
import { SmallText, Text } from "./styled-components";
import { Text } from "./styled-components";
interface EmailLayoutProps {
preview: string;

View file

@ -1,12 +1,12 @@
import { EmailLayout } from "./email-layout";
import { Button, Card, Link, Text } from "./styled-components";
import { Button, Link, Text } from "./styled-components";
import { getDomain } from "./utils";
export interface NotificationBaseProps {
name: string;
title: string;
pollUrl: string;
unsubscribeUrl: string;
disableNotificationsUrl: string;
}
export interface NotificationEmailProps extends NotificationBaseProps {
@ -16,7 +16,7 @@ export interface NotificationEmailProps extends NotificationBaseProps {
export const NotificationEmail = ({
name,
pollUrl,
unsubscribeUrl,
disableNotificationsUrl,
preview,
children,
}: React.PropsWithChildren<NotificationEmailProps>) => {
@ -26,7 +26,7 @@ export const NotificationEmail = ({
footNote={
<>
If you would like to stop receiving updates you can{" "}
<Link className="whitespace-nowrap" href={unsubscribeUrl}>
<Link className="whitespace-nowrap" href={disableNotificationsUrl}>
turn notifications off
</Link>
</>

View file

@ -12,14 +12,14 @@ export const NewCommentEmail = ({
title = "Untitled Poll",
authorName = "Someone",
pollUrl = "https://rallly.co",
unsubscribeUrl = "https://rallly.co",
disableNotificationsUrl = "https://rallly.co",
}: NewCommentEmailProps) => {
return (
<NotificationEmail
name={name}
title={title}
pollUrl={pollUrl}
unsubscribeUrl={unsubscribeUrl}
disableNotificationsUrl={disableNotificationsUrl}
preview={`${authorName} has commented on ${title}`}
>
<Text>

View file

@ -1,12 +1,5 @@
import { EmailLayout } from "./components/email-layout";
import {
Button,
Card,
Domain,
Heading,
Section,
Text,
} from "./components/styled-components";
import { Button, Domain, Section, Text } from "./components/styled-components";
import { getDomain } from "./components/utils";
interface NewParticipantConfirmationEmailProps {

View file

@ -12,14 +12,14 @@ export const NewParticipantEmail = ({
title = "Untitled Poll",
participantName = "Someone",
pollUrl = "https://rallly.co",
unsubscribeUrl = "https://rallly.co",
disableNotificationsUrl = "https://rallly.co",
}: NewParticipantEmailProps) => {
return (
<NotificationEmail
name={name}
title={title}
pollUrl={pollUrl}
unsubscribeUrl={unsubscribeUrl}
disableNotificationsUrl={disableNotificationsUrl}
preview={`${participantName} has responded`}
>
<Text>

View file

@ -1,4 +1,4 @@
import { absoluteUrl, preventWidows } from "@rallly/utils";
import { absoluteUrl } from "@rallly/utils";
import { EmailLayout } from "./components/email-layout";
import {
@ -6,7 +6,6 @@ import {
Card,
Heading,
Link,
Section,
SubHeadingText,
Text,
} from "./components/styled-components";

View file

@ -1,42 +0,0 @@
import { EmailLayout } from "./components/email-layout";
import { Button, Link, Section, Text } from "./components/styled-components";
type EnableNotificationsEmailProps = {
title: string;
name: string;
verificationLink: string;
adminLink: string;
};
export const EnableNotificationsEmail = ({
title = "Untitled Poll",
name = "John",
verificationLink = "https://rallly.co",
adminLink = "https://rallly.co",
}: EnableNotificationsEmailProps) => {
return (
<EmailLayout
recipientName={name}
preview="We need to verify your email address"
footNote={
<>
You are receiving this email because a request was made to enable
notifications for <Link href={adminLink}>{title}</Link>.
</>
}
>
<Text>
Would you like to get notified when participants respond to{" "}
<strong>{title}</strong>?
</Text>
<Section>
<Button href={verificationLink} id="verifyEmailUrl">
Yes, enable notifications
</Button>
</Section>
<Text light={true}>The link will expire in 15 minutes.</Text>
</EmailLayout>
);
};
export default EnableNotificationsEmail;

View file

@ -46,12 +46,6 @@
"db:generate": {
"dependsOn": ["^db:generate"]
},
"db:push": {
"cache": false
},
"db:deploy": {
"cache": false
},
"lint": {
"outputs": []
},