mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat: update unsubscribe route
This commit is contained in:
@@ -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
|
||||
|
||||
11
lib/types.ts
11
lib/types.ts
@@ -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: {} });
|
||||
|
||||
@@ -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(
|
||||
|
||||
21
lib/zod/schemas/notifications.ts
Normal file
21
lib/zod/schemas/notifications.ts
Normal 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 } });
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
118
pages/api/unsubscribe/yir/index.ts
Normal file
118
pages/api/unsubscribe/yir/index.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user