mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-02 18:51:52 +02:00
♻️ Move disable notifications api (#1490)
This commit is contained in:
parent
26e4d5e3e6
commit
1ecb9f6b7b
12 changed files with 106 additions and 341 deletions
|
@ -51,6 +51,11 @@ const nextConfig = {
|
|||
destination: "/settings/profile",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/auth/disable-notifications",
|
||||
destination: "/api/notifications/unsubscribe",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
experimental: {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
|
|
34
apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx
Normal file
34
apps/web/src/app/[locale]/poll/[urlId]/unsubscribe-alert.tsx
Normal 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;
|
||||
}
|
60
apps/web/src/app/api/notifications/unsubscribe/route.ts
Normal file
60
apps/web/src/app/api/notifications/unsubscribe/route.ts
Normal 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));
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue