feat: update unsubscribe route

This commit is contained in:
Marc Seitz
2024-12-12 12:57:23 +09:00
parent 6e7df38e50
commit 5ca4ceb982
7 changed files with 161 additions and 25 deletions

View File

@@ -1,8 +1,7 @@
import { logger, task } from "@trigger.dev/sdk/v3";
import prisma from "@/lib/prisma";
import { ZNotificationPreferencesSchema } from "../types";
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
type NotificationPayload = {
dataroomId: string;
@@ -79,9 +78,10 @@ export const sendDataroomChangeNotificationTask = task({
}
// Skip if notifications are disabled for this dataroom
const parsedPreferences = ZNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
const parsedPreferences =
ZViewerNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
if (
parsedPreferences.success &&
parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false

View File

@@ -284,14 +284,3 @@ export const WatermarkConfigSchema = z.object({
export type WatermarkConfig = z.infer<typeof WatermarkConfigSchema>;
export type NotionTheme = "light" | "dark";
export const ZNotificationPreferencesSchema = z
.object({
dataroom: z.record(
z.object({
enabled: z.boolean(),
}),
),
})
.optional()
.default({ dataroom: {} });

View File

@@ -5,8 +5,8 @@ const UNSUBSCRIBE_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string;
type UnsubscribePayload = {
viewerId: string;
dataroomId: string;
teamId: string;
dataroomId?: string;
exp?: number; // Expiration timestamp
};
@@ -18,7 +18,9 @@ export function generateUnsubscribeUrl(payload: UnsubscribePayload): string {
};
const token = jwt.sign(tokenPayload, JWT_SECRET);
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/dataroom?token=${token}`;
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/${
payload.dataroomId ? "dataroom" : "yir"
}?token=${token}`;
}
export function verifyUnsubscribeToken(

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const ZViewerNotificationPreferencesSchema = z
.object({
dataroom: z.record(
z.object({
enabled: z.boolean(),
}),
),
})
.optional()
.default({ dataroom: {} });
export const ZUserNotificationPreferencesSchema = z
.object({
yearInReview: z.object({
enabled: z.boolean(),
}),
})
.optional()
.default({ yearInReview: { enabled: true } });

View File

@@ -2,8 +2,8 @@ import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { ZNotificationPreferencesSchema } from "@/lib/types";
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
export default async function handle(
req: NextApiRequest,
@@ -24,7 +24,7 @@ export default async function handle(
if (req.method === "GET") {
// For GET requests, redirect to the unsubscribe page
return res.redirect(`/unsubscribe?token=${token}`);
return res.redirect(`/unsubscribe?type=dataroom&token=${token}`);
}
// Rate limit the unsubscribe request
@@ -59,6 +59,11 @@ export default async function handle(
const { viewerId, dataroomId, teamId } = payload;
if (!dataroomId) {
res.status(400).json({ message: "Dataroom ID is required" });
return;
}
// Fetch the current notification preferences
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId, teamId },
@@ -85,7 +90,7 @@ export default async function handle(
if (viewer.notificationPreferences) {
// Parse the existing preferences
const defaultPreferences = ZNotificationPreferencesSchema.safeParse(
const defaultPreferences = ZViewerNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);

View File

@@ -0,0 +1,118 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
import { ZUserNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST" && req.method !== "GET") {
res.status(405).json({ message: "Method Not Allowed" });
return;
}
const { token } = req.query as { token: string };
try {
if (!token) {
res.status(400).json({ message: "Token is required" });
return;
}
if (req.method === "GET") {
// For GET requests, redirect to the unsubscribe page
return res.redirect(`/unsubscribe?type=yir&token=${token}`);
}
// Rate limit the unsubscribe request
const ipAddress =
req.headers["x-forwarded-for"] || req.socket.remoteAddress || "127.0.0.1";
const { success, limit, reset, remaining } = await ratelimit(
5,
"1 m",
).limit(`unsubscribe_${ipAddress}`);
// Set rate limit headers
res.setHeader("Retry-After", reset.toString());
res.setHeader("X-RateLimit-Limit", limit.toString());
res.setHeader("X-RateLimit-Remaining", remaining.toString());
res.setHeader("X-RateLimit-Reset", reset.toString());
if (!success) {
return res.status(429).json({ error: "Too many requests" });
}
const payload = verifyUnsubscribeToken(token);
if (!payload) {
res.status(404).json({ message: "Invalid token" });
return;
}
if (payload.exp && payload.exp < new Date().getTime() / 1000) {
res.status(404).json({ message: "Token expired" });
return;
}
const { viewerId, teamId } = payload;
// Fetch the current notification preferences
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: viewerId,
teamId,
},
},
select: { notificationPreferences: true },
});
if (!userTeam) {
res.status(404).json({ message: "User not found" });
return;
}
// Parse existing preferences or initialize empty object
let updatedPreferences;
if (userTeam.notificationPreferences) {
// Parse the existing preferences
const defaultPreferences = ZUserNotificationPreferencesSchema.safeParse(
userTeam.notificationPreferences,
);
// Update the preferences to opt out of year in review
updatedPreferences = {
...defaultPreferences.data,
yearInReview: { enabled: false },
};
} else {
// If no preferences exist, initialize with year in review preference
updatedPreferences = {
yearInReview: { enabled: false },
};
}
// Update the user's notification preferences in the database
await prisma.userTeam.update({
where: {
userId_teamId: {
userId: viewerId,
teamId,
},
},
data: {
notificationPreferences: updatedPreferences,
},
});
res.status(200).json({
message: "Successfully unsubscribed from Year in Review emails.",
});
} catch (error) {
res.status(500).json({ message: (error as Error).message });
}
}

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
export default function UnsubscribePage() {
const router = useRouter();
const { token } = router.query as { token: string };
const { type, token } = router.query as { type: string; token: string };
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
@@ -19,7 +19,7 @@ export default function UnsubscribePage() {
return;
}
setLoading(true);
const response = await fetch(`/api/unsubscribe/dataroom?token=${token}`, {
const response = await fetch(`/api/unsubscribe/${type}?token=${token}`, {
method: "POST",
});
const data = await response.json();
@@ -42,7 +42,8 @@ export default function UnsubscribePage() {
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold">
Unsubscribe from Notifications
Unsubscribe from {type === "yir" ? "Year in Review" : "Dataroom"}
Notifications
</h1>
{status === "error" ? (
@@ -52,7 +53,7 @@ export default function UnsubscribePage() {
) : (
<p className="mb-6 text-center text-gray-600">
Click the button below to unsubscribe from notifications for this
dataroom.
{type === "yir" ? "year in review" : "dataroom"}.
</p>
)}