♻️ Move disable notifications api (#1490)

This commit is contained in:
Luke Vella 2025-01-13 11:27:30 +00:00 committed by GitHub
parent 26e4d5e3e6
commit 1ecb9f6b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 106 additions and 341 deletions

View file

@ -51,6 +51,11 @@ const nextConfig = {
destination: "/settings/profile",
permanent: true,
},
{
source: "/auth/disable-notifications",
destination: "/api/notifications/unsubscribe",
permanent: true,
},
];
},
experimental: {

View file

@ -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 <a>{newUrl}</a>",
"notRegistered": "Don't have an account? <a>Register</a>",
"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 <a>billing page</a>."
"cancelAnytime": "Cancel anytime from your <a>billing page</a>.",
"unsubscribeToastTitle": "You have disabled notifications",
"unsubscribeToastDescription": "You will no longer receive notifications for this poll"
}

View file

@ -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 (
<div className="space-y-3 lg:space-y-4">
<UnsubscribeAlert />
<PollHeader />
<GuestPollAlert />
<EventCard />

View file

@ -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;
}

View file

@ -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<DisableNotificationsPayload>(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));
};

View file

@ -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<DisableNotificationsPayload>(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,
};
};

View file

@ -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 (
<PageDialog icon={XCircle}>
<PageDialogHeader>
<PageDialogTitle>
<Trans i18nKey="authErrorTitle" defaults="Login Error" />
</PageDialogTitle>
<PageDialogDescription>
<Trans
i18nKey="authErrorDescription"
defaults="There was an error logging you in. Please try again."
/>
</PageDialogDescription>
</PageDialogHeader>
<PageDialogFooter>
<Button asChild variant="primary">
<Link href="/login">
<Trans i18nKey="authErrorCta" defaults="Go to login page" />
</Link>
</Button>
</PageDialogFooter>
</PageDialog>
);
};
export default Page;

View file

@ -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<AppPropsWithLayout> = ({ Component, pageProps }) => {
if (process.env.NEXT_PUBLIC_MAINTENANCE_MODE === "1") {
return <Maintenance />;
}
const getLayout = Component.getLayout ?? ((page) => page);
const children = <Component {...pageProps} />;
return (
<SessionProvider>
<LazyMotion features={domMax}>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=5, user-scalable=yes"
/>
</Head>
<style jsx global>{`
html {
--font-inter: ${inter.style.fontFamily};
}
`}</style>
<I18nProvider>
<TooltipProvider delayDuration={200}>
<UserProvider>
<ConnectedDayjsProvider>
{Component.isAuthRequired ? (
<Auth>{getLayout(children)}</Auth>
) : (
getLayout(children)
)}
</ConnectedDayjsProvider>
</UserProvider>
</TooltipProvider>
</I18nProvider>
</LazyMotion>
</SessionProvider>
);
};
export default trpc.withTRPC(MyApp);

View file

@ -1,110 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
import React from "react";
export default function Document() {
return (
<Html>
<Head>
<link
rel="apple-touch-icon-precomposed"
sizes="57x57"
href="/apple-touch-icon-57x57.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="114x114"
href="/apple-touch-icon-114x114.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="72x72"
href="/apple-touch-icon-72x72.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="144x144"
href="/apple-touch-icon-144x144.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="60x60"
href="/apple-touch-icon-60x60.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="120x120"
href="/apple-touch-icon-120x120.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="76x76"
href="/apple-touch-icon-76x76.png"
/>
<link
rel="apple-touch-icon-precomposed"
sizes="152x152"
href="/apple-touch-icon-152x152.png"
/>
<link
rel="icon"
type="image/png"
href="/favicon-196x196.png"
sizes="196x196"
/>
<link
rel="icon"
type="image/png"
href="/favicon-96x96.png"
sizes="96x96"
/>
<link
rel="icon"
type="image/png"
href="/favicon-32x32.png"
sizes="32x32"
/>
<link
rel="icon"
type="image/png"
href="/favicon-16x16.png"
sizes="16x16"
/>
<link
rel="icon"
type="image/png"
href="/favicon-128x128.png"
sizes="128x128"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.json" />
<meta name="robots" content="noindex,nofollow" />
<meta name="application-name" content="Rallly" />
<meta name="msapplication-TileColor" content="#FFFFFF" />
<meta name="msapplication-TileImage" content="/mstile-144x144.png" />
<meta
name="msapplication-square70x70logo"
content="/mstile-70x70.png"
/>
<meta
name="msapplication-square150x150logo"
content="/mstile-150x150.png"
/>
<meta
name="msapplication-wide310x150logo"
content="/mstile-310x150.png"
/>
<meta
name="msapplication-square310x310logo"
content="/mstile-310x310.png"
/>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="theme-color" content="#F3F4F6" />
</Head>
<body>
<Main />
<NextScript />
<div id="portal"></div>
</body>
</Html>
);
}

View file

@ -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<ErrorProps> = (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 <NextErrorComponent statusCode={props.statusCode} />;
};
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;

View file

@ -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,
},

View file

@ -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,
},