mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-15 18:06:48 +02:00
✨ Skip user details step for logged in users (#602)
This commit is contained in:
parent
f858bcc5f4
commit
d8e3dcd357
41 changed files with 548 additions and 636 deletions
|
@ -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"] }]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -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];
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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 />,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
158
apps/web/src/pages/auth/disable-notifications.tsx
Normal file
158
apps/web/src/pages/auth/disable-notifications.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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}`}
|
||||
|
|
|
@ -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: "" };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -30,7 +30,6 @@ export const user = router({
|
|||
select: {
|
||||
title: true,
|
||||
closed: true,
|
||||
verified: true,
|
||||
createdAt: true,
|
||||
adminUrlId: true,
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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) => {
|
||||
|
|
15
apps/web/src/utils/trpc/hooks.ts
Normal file
15
apps/web/src/utils/trpc/hooks.ts
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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");
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
|
@ -46,12 +46,6 @@
|
|||
"db:generate": {
|
||||
"dependsOn": ["^db:generate"]
|
||||
},
|
||||
"db:push": {
|
||||
"cache": false
|
||||
},
|
||||
"db:deploy": {
|
||||
"cache": false
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue