mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-16 18:36:24 +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",
|
"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"] }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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", {
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 />,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
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}>
|
<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}`}
|
||||||
|
|
|
@ -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: "" };
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
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) => {
|
||||||
|
|
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 = {
|
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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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")
|
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")
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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";
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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": {
|
"db:generate": {
|
||||||
"dependsOn": ["^db:generate"]
|
"dependsOn": ["^db:generate"]
|
||||||
},
|
},
|
||||||
"db:push": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"db:deploy": {
|
|
||||||
"cache": false
|
|
||||||
},
|
|
||||||
"lint": {
|
"lint": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue