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

View file

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

View file

@ -1,46 +1,18 @@
import { AnimatePresence, m } from "framer-motion"; import { AnimatePresence, m } from "framer-motion";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import React from "react"; import React from "react";
import toast from "react-hot-toast";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import Share from "@/components/icons/share.svg"; import Share from "@/components/icons/share.svg";
import { usePostHog } from "@/utils/posthog";
import { useParticipants } from "./participants-provider"; import { useParticipants } from "./participants-provider";
import ManagePoll from "./poll/manage-poll"; import ManagePoll from "./poll/manage-poll";
import { useUpdatePollMutation } from "./poll/mutations";
import NotificationsToggle from "./poll/notifications-toggle"; import NotificationsToggle from "./poll/notifications-toggle";
import { usePoll } from "./poll-context";
import Sharing from "./sharing"; import Sharing from "./sharing";
export const AdminControls = (props: { children?: React.ReactNode }) => { export const AdminControls = (props: { children?: React.ReactNode }) => {
const { urlId } = usePoll();
const { t } = useTranslation("app"); 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 { participants } = useParticipants();
const [isSharingVisible, setIsSharingVisible] = React.useState( const [isSharingVisible, setIsSharingVisible] = React.useState(

View file

@ -13,7 +13,10 @@ export const LoginModal: React.FunctionComponent<{
const [defaultEmail, setDefaultEmail] = React.useState(""); const [defaultEmail, setDefaultEmail] = React.useState("");
return ( 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"> <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" /> <Logo className="text-2xl" />
</div> </div>

View file

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

View file

@ -42,15 +42,8 @@ const Discussion: React.FunctionComponent = () => {
const posthog = usePostHog(); const posthog = usePostHog();
const addComment = trpc.polls.comments.add.useMutation({ const addComment = trpc.polls.comments.add.useMutation({
onSuccess: (newComment) => { onSuccess: () => {
posthog?.capture("created comment"); 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 ChevronDown from "@/components/icons/chevron-down.svg";
import { useParticipants } from "../../participants-provider"; import { useParticipants } from "../../participants-provider";
import { usePoll } from "../../poll-context";
import { ConnectedScoreSummary } from "../score-summary"; import { ConnectedScoreSummary } from "../score-summary";
import UserAvatar from "../user-avatar"; import UserAvatar from "../user-avatar";
import VoteIcon from "../vote-icon"; import VoteIcon from "../vote-icon";
@ -181,37 +180,13 @@ const PollOption: React.FunctionComponent<PollOptionProps> = ({
onChange, onChange,
participants, participants,
editable = false, editable = false,
yesScore,
optionId, optionId,
}) => { }) => {
const { getVote } = usePoll();
const showVotes = !!(selectedParticipantId || editable); const showVotes = !!(selectedParticipantId || editable);
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const selectorRef = React.useRef<HTMLButtonElement>(null); const selectorRef = React.useRef<HTMLButtonElement>(null);
const [active, setActive] = React.useState(false); 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 ( return (
<div <div
className={clsx("space-y-4 overflow-hidden p-3", { className={clsx("space-y-4 overflow-hidden p-3", {

View file

@ -79,10 +79,10 @@ export const useUpdatePollMutation = () => {
const queryClient = trpc.useContext(); const queryClient = trpc.useContext();
const posthog = usePostHog(); const posthog = usePostHog();
return trpc.polls.update.useMutation({ return trpc.polls.update.useMutation({
onSuccess: (data) => { onSuccess: (_data, { urlId }) => {
queryClient.polls.invalidate(); queryClient.polls.invalidate();
posthog?.capture("updated poll", { 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 * as React from "react";
import { useLoginModal } from "@/components/auth/login-modal";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import Bell from "@/components/icons/bell.svg"; import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.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 { trpc } from "@/utils/trpc";
import { usePollByAdmin } from "@/utils/trpc/hooks";
import { usePoll } from "../poll-context"; import { usePoll } from "../poll-context";
import Tooltip from "../tooltip"; 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 NotificationsToggle: React.FunctionComponent = () => {
const { poll, urlId } = usePoll(); const { poll } = usePoll();
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [isUpdatingNotifications, setIsUpdatingNotifications] =
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation(); const { data } = usePollByAdmin();
const requestEnableNotifications = const watchers = data.watchers ?? [];
trpc.polls.enableNotifications.useMutation();
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 ( return (
<Tooltip <Tooltip
content={ content={
<div className="max-w-md"> <div className="max-w-md">
{requestEnableNotifications.isSuccess ? ( {user.isGuest
<Trans ? t("notificationsGuest")
t={t} : isWatching
i18nKey="unverifiedMessage" ? t("notificationsOn")
values={{ : t("notificationsOff")}
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")
)}
</div> </div>
} }
> >
<Button <Button
data-testid="notifications-toggle" data-testid="notifications-toggle"
loading={ loading={isUpdating}
isUpdatingNotifications || requestEnableNotifications.isLoading disabled={poll.demo}
} icon={isWatching ? <Bell /> : <BellCrossed />}
icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
disabled={requestEnableNotifications.isSuccess}
onClick={async () => { onClick={async () => {
if (!poll.verified) { if (user.isGuest) {
await requestEnableNotifications.mutateAsync({ // ask to log in
adminUrlId: poll.adminUrlId, openLoginModal();
});
} else { } else {
setIsUpdatingNotifications(true); // toggle
updatePollMutation( if (isWatching) {
{ await unwatch.mutateAsync({ pollId: poll.id });
urlId, } else {
notifications: !poll.notifications, await watch.mutateAsync({ pollId: poll.id });
}, }
{
onSuccess: () => {
setIsUpdatingNotifications(false);
},
},
);
} }
}} }}
/> />

View file

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

View file

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

View file

@ -105,7 +105,7 @@ const Tooltip: React.FunctionComponent<TooltipProps> = ({
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<m.div <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" initial="hidden"
transition={{ transition={{
duration: 0.1, duration: 0.1,

View file

@ -8,7 +8,7 @@ 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 { withAuthIfRequired, withSessionSsr } from "@/utils/auth"; 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 { withPageTranslations } from "@/utils/with-page-translations";
import { AdminControls } from "../../components/admin-control"; import { AdminControls } from "../../components/admin-control";
@ -18,7 +18,7 @@ import { NextPageWithLayout } from "../../types";
const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => { const Page: NextPageWithLayout<{ urlId: string }> = ({ urlId }) => {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const pollQuery = trpc.polls.getByAdminUrlId.useQuery({ urlId }); const pollQuery = usePollByAdmin();
const poll = pollQuery.data; 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 // Delete polls
// Using execute raw to bypass soft delete middelware // Using execute raw to bypass soft delete middelware
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join( 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}> <PollContextProvider poll={poll} urlId={urlId} admin={false}>
<ModalProvider> <ModalProvider>
<div className="space-y-3 p-3 sm:space-y-4 sm:p-4"> <div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
{user.id === poll.user.id ? ( {user.id === poll.userId ? (
<Link <Link
className="btn-default" className="btn-default"
href={`/admin/${poll.adminUrlId}`} href={`/admin/${poll.adminUrlId}`}

View file

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

View file

@ -1,7 +1,10 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { z } from "zod"; 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"; import { publicProcedure, router } from "../../trpc";
export const comments = router({ export const comments = router({
@ -39,13 +42,63 @@ export const comments = router({
authorName, authorName,
userId: user.id, userId: user.id,
}, },
select: {
id: true,
createdAt: true,
authorName: true,
content: true,
poll: {
select: {
title: true,
adminUrlId: true,
},
},
},
}); });
await sendNotification(pollId, { const watchers = await prisma.watcher.findMany({
type: "newComment", where: {
authorName: newComment.authorName, 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; return newComment;
}), }),
delete: publicProcedure delete: publicProcedure

View file

@ -78,7 +78,6 @@ export const demo = router({
location: "Starbucks, 901 New York Avenue", 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!`, 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", authorName: "Johnny",
verified: true,
demo: true, demo: true,
adminUrlId, adminUrlId,
participantUrlId: await nanoid(), participantUrlId: await nanoid(),

View file

@ -1,10 +1,10 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails"; import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils"; import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { sendNotification } from "../../../utils/api-utils"; import { createToken, DisableNotificationsPayload } from "../../../utils/auth";
import { createToken } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc"; import { publicProcedure, router } from "../../trpc";
export const participants = router({ export const participants = router({
@ -61,7 +61,17 @@ export const participants = router({
) )
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => { .mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
const user = ctx.session.user; 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: { data: {
pollId: pollId, pollId: pollId,
name: name, 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>[] = []; const emailsToSend: Promise<void>[] = [];
if (email) { if (email) {
const token = await createToken( const token = await createToken(
@ -112,12 +112,44 @@ export const participants = router({
); );
} }
emailsToSend.push( const watchers = await prisma.watcher.findMany({
sendNotification(pollId, { where: {
type: "newParticipant", pollId,
participantName: name, },
}), 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(
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); await Promise.all(emailsToSend);

View file

@ -30,7 +30,6 @@ export const user = router({
select: { select: {
title: true, title: true,
closed: true, closed: true,
verified: true,
createdAt: true, createdAt: true,
adminUrlId: 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; code: string;
}; };
export type EnableNotificationsTokenPayload = {
adminUrlId: string;
};
export type RegisteredUserSession = { export type RegisteredUserSession = {
isGuest: false; isGuest: false;
id: string; id: string;
@ -51,6 +47,11 @@ export type GuestUserSession = {
id: string; id: string;
}; };
export type DisableNotificationsPayload = {
pollId: string;
watcherId: number;
};
export type UserSession = GuestUserSession | RegisteredUserSession; export type UserSession = GuestUserSession | RegisteredUserSession;
const setUser = async (session: IronSession) => { 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 = { export type GetPollApiResponse = {
id: string; id: string;
title: string; title: string;
authorName: string;
location: string | null; location: string | null;
description: string | null; description: string | null;
options: Option[]; options: Option[];
user: User; user: User | null;
timeZone: string | null; timeZone: string | null;
adminUrlId: string; adminUrlId: string;
participantUrlId: string; participantUrlId: string;
verified: boolean;
closed: boolean; closed: boolean;
legacy: boolean; legacy: boolean;
demo: boolean; demo: boolean;
notifications: boolean;
createdAt: Date; createdAt: Date;
deleted: boolean; deleted: boolean;
}; };

View file

@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { load } from "cheerio";
import smtpTester, { SmtpTester } from "smtp-tester"; import smtpTester, { SmtpTester } from "smtp-tester";
test.describe.serial(() => { test.describe.serial(() => {
@ -60,34 +59,11 @@ test.describe.serial(() => {
pollUrl = page.url(); pollUrl = page.url();
}); });
test("enable notifications", async ({ page, baseURL }) => { test("notifications", async ({ page }) => {
await page.goto(pollUrl); await page.goto(pollUrl);
await page.getByTestId("notifications-toggle").click(); await page.getByTestId("notifications-toggle").click();
const { email } = await mailServer.captureOne("john.doe@email.com", { expect(page.getByTestId("login-modal")).toBeVisible();
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");
}); });
// delete the poll we just created // delete the poll we just created

View file

@ -6,7 +6,7 @@
"dev": "dotenv -- turbo dev", "dev": "dotenv -- turbo dev",
"start": "turbo run start --filter=@rallly/web...", "start": "turbo run start --filter=@rallly/web...",
"build": "turbo run build --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:generate": "turbo db:generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"test": "yarn workspace @rallly/web test", "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") updatedAt DateTime? @updatedAt @map("updated_at")
comments Comment[] comments Comment[]
polls Poll[] polls Poll[]
Watcher Watcher[]
@@map("users") @@map("users")
} }
@ -36,19 +37,18 @@ model Poll {
type PollType type PollType
description String? description String?
location String? location String?
user User @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String @map("user_id") userId String @map("user_id")
votes Vote[] votes Vote[]
timeZone String? @map("time_zone") timeZone String? @map("time_zone")
verified Boolean @default(false)
options Option[] options Option[]
participants Participant[] participants Participant[]
authorName String @default("") @map("author_name") watchers Watcher[]
authorName String? @map("author_name")
demo Boolean @default(false) demo Boolean @default(false)
comments Comment[] comments Comment[]
legacy Boolean @default(false) legacy Boolean @default(false)
closed Boolean @default(false) closed Boolean @default(false)
notifications Boolean @default(false)
deleted Boolean @default(false) deleted Boolean @default(false)
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
touchedAt DateTime @default(now()) @map("touched_at") touchedAt DateTime @default(now()) @map("touched_at")
@ -59,6 +59,19 @@ model Poll {
@@map("polls") @@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 { model Participant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -116,7 +129,7 @@ model Comment {
poll Poll @relation(fields: [pollId], references: [id]) poll Poll @relation(fields: [pollId], references: [id])
pollId String @map("poll_id") pollId String @map("poll_id")
authorName String @map("author_name") authorName String @map("author_name")
user User? @relation(fields:[userId], references: [id]) user User? @relation(fields: [userId], references: [id])
userId String? @map("user_id") userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at") updatedAt DateTime? @updatedAt @map("updated_at")

View file

@ -3,7 +3,8 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "email dev --port 3333 --dir ./src/templates" "dev": "email dev --port 3333 --dir ./src/templates",
"lint": "eslint ./src"
}, },
"main": "./src/index.tsx", "main": "./src/index.tsx",
"types": "./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-participant-confirmation";
export * from "./templates/new-poll"; export * from "./templates/new-poll";
export * from "./templates/register"; export * from "./templates/register";
export * from "./templates/turn-on-notifications";

View file

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

View file

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

View file

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

View file

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

View file

@ -12,14 +12,14 @@ export const NewParticipantEmail = ({
title = "Untitled Poll", title = "Untitled Poll",
participantName = "Someone", participantName = "Someone",
pollUrl = "https://rallly.co", pollUrl = "https://rallly.co",
unsubscribeUrl = "https://rallly.co", disableNotificationsUrl = "https://rallly.co",
}: NewParticipantEmailProps) => { }: NewParticipantEmailProps) => {
return ( return (
<NotificationEmail <NotificationEmail
name={name} name={name}
title={title} title={title}
pollUrl={pollUrl} pollUrl={pollUrl}
unsubscribeUrl={unsubscribeUrl} disableNotificationsUrl={disableNotificationsUrl}
preview={`${participantName} has responded`} preview={`${participantName} has responded`}
> >
<Text> <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 { EmailLayout } from "./components/email-layout";
import { import {
@ -6,7 +6,6 @@ import {
Card, Card,
Heading, Heading,
Link, Link,
Section,
SubHeadingText, SubHeadingText,
Text, Text,
} from "./components/styled-components"; } 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": { "db:generate": {
"dependsOn": ["^db:generate"] "dependsOn": ["^db:generate"]
}, },
"db:push": {
"cache": false
},
"db:deploy": {
"cache": false
},
"lint": { "lint": {
"outputs": [] "outputs": []
}, },