From 1ecb9f6b7b19ce1e8ffa124a8c9879fa1068d4f9 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Mon, 13 Jan 2025 11:27:30 +0000 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Move=20disable=20notificat?= =?UTF-8?q?ions=20api=20(#1490)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/next.config.js | 5 + apps/web/public/locales/en/app.json | 8 +- .../app/[locale]/poll/[urlId]/admin-page.tsx | 2 + .../poll/[urlId]/unsubscribe-alert.tsx | 34 ++++++ .../api/notifications/unsubscribe/route.ts | 60 ++++++++++ .../[locale]/auth/disable-notifications.tsx | 59 ---------- apps/web/src/pages/[locale]/auth/error.tsx | 39 ------- apps/web/src/pages/_app.tsx | 88 -------------- apps/web/src/pages/_document.tsx | 110 ------------------ apps/web/src/pages/_error.tsx | 38 ------ apps/web/src/trpc/routers/polls/comments.ts | 2 +- .../src/trpc/routers/polls/participants.ts | 2 +- 12 files changed, 106 insertions(+), 341 deletions(-) create mode 100644 apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx create mode 100644 apps/web/src/app/api/notifications/unsubscribe/route.ts delete mode 100644 apps/web/src/pages/[locale]/auth/disable-notifications.tsx delete mode 100644 apps/web/src/pages/[locale]/auth/error.tsx delete mode 100644 apps/web/src/pages/_app.tsx delete mode 100644 apps/web/src/pages/_document.tsx delete mode 100644 apps/web/src/pages/_error.tsx diff --git a/apps/web/next.config.js b/apps/web/next.config.js index ea9f0f120..13f80278a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -51,6 +51,11 @@ const nextConfig = { destination: "/settings/profile", permanent: true, }, + { + source: "/auth/disable-notifications", + destination: "/api/notifications/unsubscribe", + permanent: true, + }, ]; }, experimental: { diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 96ea5595d..d1f96b92a 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -198,16 +198,12 @@ "requireParticipantEmailLabel": "Make email address required for participants", "hideParticipantsLabel": "Hide participant list from other participants", "hideScoresLabel": "Hide scores until after a participant has voted", - "authErrorTitle": "Login Error", - "authErrorDescription": "There was an error logging you in. Please try again.", - "authErrorCta": "Go to login page", "continueAs": "Continue as", "pageMovedDescription": "Redirecting to {newUrl}", "notRegistered": "Don't have an account? Register", "unlockFeatures": "Unlock all Pro features.", "pollStatusFinalized": "Finalized", "share": "Share", - "pageXOfY": "Page {currentPage} of {pageCount}", "noParticipants": "No participants", "userId": "User ID", "aboutGuest": "Guest User", @@ -281,5 +277,7 @@ "savePercentage": "Save {percentage}%", "1month": "1 month", "subscribe": "Subscribe", - "cancelAnytime": "Cancel anytime from your billing page." + "cancelAnytime": "Cancel anytime from your billing page.", + "unsubscribeToastTitle": "You have disabled notifications", + "unsubscribeToastDescription": "You will no longer receive notifications for this poll" } diff --git a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx index 52adbdae4..84f341609 100644 --- a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx +++ b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx @@ -9,11 +9,13 @@ import { useTouchBeacon } from "@/components/poll/use-touch-beacon"; import { VotingForm } from "@/components/poll/voting-form"; import { GuestPollAlert } from "./guest-poll-alert"; +import { UnsubscribeAlert } from "./unsubscribe-alert"; export function AdminPage() { useTouchBeacon(); return (
+ diff --git a/apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx b/apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx new file mode 100644 index 000000000..d37cfcc51 --- /dev/null +++ b/apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useToast } from "@rallly/ui/hooks/use-toast"; +import Cookies from "js-cookie"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; + +export function UnsubscribeAlert() { + const { toast } = useToast(); + const { t } = useTranslation("app"); + + const urlId = useParams<{ urlId: string }>()?.urlId; + + useEffect(() => { + if (!urlId) return; + const cookieName = `notifications-unsubscribed-${urlId}`; + const unsubscribed = Cookies.get(cookieName); + if (unsubscribed) { + Cookies.remove(cookieName); + toast({ + title: t("unsubscribeToastTitle", { + defaultValue: "You have disabled notifications", + }), + description: t("unsubscribeToastDescription", { + defaultValue: + "You will no longer receive notifications for this poll", + }), + }); + } + }, [t, toast, urlId]); + + return null; +} diff --git a/apps/web/src/app/api/notifications/unsubscribe/route.ts b/apps/web/src/app/api/notifications/unsubscribe/route.ts new file mode 100644 index 000000000..4a9117259 --- /dev/null +++ b/apps/web/src/app/api/notifications/unsubscribe/route.ts @@ -0,0 +1,60 @@ +import { prisma } from "@rallly/database"; +import { cookies } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getServerSession } from "@/auth"; +import type { DisableNotificationsPayload } from "@/trpc/types"; +import { decryptToken } from "@/utils/session"; + +export const GET = async (req: NextRequest) => { + const token = req.nextUrl.searchParams.get("token"); + + if (!token) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + const session = await getServerSession(); + + if (!session || !session.user.email) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + const payload = await decryptToken(token); + + if (!payload) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + const watcher = await prisma.watcher.findFirst({ + where: { + userId: session.user.id, + pollId: payload.pollId, + }, + }); + + if (watcher) { + await prisma.watcher.delete({ + where: { + id: watcher.id, + }, + select: { + pollId: true, + }, + }); + + // Set a session cookie to indicate that the user has unsubscribed + cookies().set(`notifications-unsubscribed-${watcher.pollId}`, "1", { + path: "/", + httpOnly: false, + secure: false, + sameSite: "lax", + maxAge: 5, + }); + + // redirect to poll + return NextResponse.redirect(new URL(`/poll/${watcher.pollId}`, req.url)); + } + + return NextResponse.redirect(new URL(`/poll/${payload.pollId}`, req.url)); +}; diff --git a/apps/web/src/pages/[locale]/auth/disable-notifications.tsx b/apps/web/src/pages/[locale]/auth/disable-notifications.tsx deleted file mode 100644 index 2a99ae50c..000000000 --- a/apps/web/src/pages/[locale]/auth/disable-notifications.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { prisma } from "@rallly/database"; -import type { GetServerSideProps } from "next"; - -import { getServerSession } from "@/auth"; -import type { DisableNotificationsPayload } from "@/trpc/types"; -import { decryptToken } from "@/utils/session"; - -const Page = () => { - return null; -}; - -export default Page; - -export const getServerSideProps: GetServerSideProps = async (ctx) => { - const token = ctx.query.token as string; - const session = await getServerSession(ctx.req, ctx.res); - - if (!session || session.user.email === null) { - return { - props: {}, - redirect: { - destination: - "/login?callbackUrl=" + encodeURIComponent(ctx.req.url ?? "/"), - }, - }; - } - - if (session && token) { - const payload = await decryptToken(token); - if (payload) { - const watcher = await prisma.watcher.findFirst({ - where: { - userId: session.user.id, - pollId: payload.pollId, - }, - }); - - if (watcher) { - await prisma.watcher.delete({ - where: { - id: watcher.id, - }, - }); - } - - return { - props: {}, - redirect: { - destination: `/poll/${payload.pollId}`, - }, - }; - } - } - - return { - props: {}, - notFound: true, - }; -}; diff --git a/apps/web/src/pages/[locale]/auth/error.tsx b/apps/web/src/pages/[locale]/auth/error.tsx deleted file mode 100644 index 208a6d2a1..000000000 --- a/apps/web/src/pages/[locale]/auth/error.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Button } from "@rallly/ui/button"; -import { XCircle } from "lucide-react"; -import Link from "next/link"; - -import { - PageDialog, - PageDialogDescription, - PageDialogFooter, - PageDialogHeader, - PageDialogTitle, -} from "@/components/page-dialog"; -import { Trans } from "@/components/trans"; - -const Page = () => { - return ( - - - - - - - - - - - - - - ); -}; - -export default Page; diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx deleted file mode 100644 index 1abf3b4c1..000000000 --- a/apps/web/src/pages/_app.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import "react-big-calendar/lib/css/react-big-calendar.css"; -import "tailwindcss/tailwind.css"; -import "../style.css"; - -import { TooltipProvider } from "@rallly/ui/tooltip"; -import { domMax, LazyMotion } from "framer-motion"; -import type { NextPage } from "next"; -import type { AppProps } from "next/app"; -import { Inter } from "next/font/google"; -import Head from "next/head"; -import { SessionProvider, signIn, useSession } from "next-auth/react"; -import React from "react"; - -import Maintenance from "@/components/maintenance"; -import { UserProvider } from "@/components/user-provider"; -import { I18nProvider } from "@/i18n/client"; -import { trpc } from "@/trpc/client"; -import { ConnectedDayjsProvider } from "@/utils/dayjs"; - -import type { NextPageWithLayout } from "../types"; - -const inter = Inter({ - subsets: ["latin"], - display: "swap", -}); - -type AppPropsWithLayout = AppProps & { - Component: NextPageWithLayout; -}; - -const Auth = ({ children }: { children: React.ReactNode }) => { - const session = useSession(); - const isAuthenticated = !!session.data?.user.email; - - React.useEffect(() => { - if (!isAuthenticated) { - signIn(); - } - }, [isAuthenticated]); - - if (isAuthenticated) { - return <>{children}; - } - - return null; -}; - -const MyApp: NextPage = ({ Component, pageProps }) => { - if (process.env.NEXT_PUBLIC_MAINTENANCE_MODE === "1") { - return ; - } - - const getLayout = Component.getLayout ?? ((page) => page); - const children = ; - - return ( - - - - - - - - - - - {Component.isAuthRequired ? ( - {getLayout(children)} - ) : ( - getLayout(children) - )} - - - - - - - ); -}; - -export default trpc.withTRPC(MyApp); diff --git a/apps/web/src/pages/_document.tsx b/apps/web/src/pages/_document.tsx deleted file mode 100644 index 98e16e854..000000000 --- a/apps/web/src/pages/_document.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Head, Html, Main, NextScript } from "next/document"; -import React from "react"; - -export default function Document() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - ); -} diff --git a/apps/web/src/pages/_error.tsx b/apps/web/src/pages/_error.tsx deleted file mode 100644 index ae1df3b30..000000000 --- a/apps/web/src/pages/_error.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher. - * - * This page is loaded by Nextjs: - * - on the server, when data-fetching methods throw or reject - * - on the client, when `getInitialProps` throws or rejects - * - on the client, when a React lifecycle method throws or rejects, and it's - * caught by the built-in Nextjs error boundary - * - * See: - * - https://nextjs.org/docs/basic-features/data-fetching/overview - * - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props - * - https://reactjs.org/docs/error-boundaries.html - */ - -import * as Sentry from "@sentry/nextjs"; -import type { NextPage } from "next"; -import type { ErrorProps } from "next/error"; -import NextErrorComponent from "next/error"; - -const CustomErrorComponent: NextPage = (props) => { - // If you're using a Nextjs version prior to 12.2.1, uncomment this to - // compensate for https://github.com/vercel/next.js/issues/8592 - // Sentry.captureUnderscoreErrorException(props); - - return ; -}; - -CustomErrorComponent.getInitialProps = async (contextData) => { - // In case this is running in a serverless function, await this in order to give Sentry - // time to send the error before the lambda exits - await Sentry.captureUnderscoreErrorException(contextData); - - // This will contain the status code of the response - return NextErrorComponent.getInitialProps(contextData); -}; - -export default CustomErrorComponent; diff --git a/apps/web/src/trpc/routers/polls/comments.ts b/apps/web/src/trpc/routers/polls/comments.ts index b52931d33..18e4efde0 100644 --- a/apps/web/src/trpc/routers/polls/comments.ts +++ b/apps/web/src/trpc/routers/polls/comments.ts @@ -93,7 +93,7 @@ export const comments = router({ authorName, pollUrl: absoluteUrl(`/poll/${poll.id}`), disableNotificationsUrl: absoluteUrl( - `/auth/disable-notifications?token=${token}`, + `/api/notifications/unsubscribe?token=${token}`, ), title: poll.title, }, diff --git a/apps/web/src/trpc/routers/polls/participants.ts b/apps/web/src/trpc/routers/polls/participants.ts index 1c0ba04b0..74c5e34c7 100644 --- a/apps/web/src/trpc/routers/polls/participants.ts +++ b/apps/web/src/trpc/routers/polls/participants.ts @@ -169,7 +169,7 @@ export const participants = router({ participantName: participant.name, pollUrl: absoluteUrl(`/poll/${participant.poll.id}`), disableNotificationsUrl: absoluteUrl( - `/auth/disable-notifications?token=${token}`, + `/api/notifications/unsubscribe?token=${token}`, ), title: participant.poll.title, },