Merge pull request #2093 from mfts/feat/dataroom-visitor-notifications
feat: add notification settings for visitors
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { receiver } from "@/lib/cron";
|
||||
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
// Runs daily at 9 AM UTC (0 9 * * *)
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
if (process.env.VERCEL === "1") {
|
||||
const isValid = await receiver.verify({
|
||||
signature: req.headers.get("Upstash-Signature") || "",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!isValid) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processDataroomDigest("daily");
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
await log({
|
||||
message: `Daily dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
|
||||
type: "cron",
|
||||
mention: true,
|
||||
});
|
||||
return NextResponse.json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { receiver } from "@/lib/cron";
|
||||
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
// Runs weekly on Monday at 9 AM UTC (0 9 * * 1)
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
if (process.env.VERCEL === "1") {
|
||||
const isValid = await receiver.verify({
|
||||
signature: req.headers.get("Upstash-Signature") || "",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!isValid) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processDataroomDigest("weekly");
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
await log({
|
||||
message: `Weekly dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
|
||||
type: "cron",
|
||||
mention: true,
|
||||
});
|
||||
return NextResponse.json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
type DocumentChange = {
|
||||
documentName: string;
|
||||
};
|
||||
|
||||
export default function DataroomDigestNotification({
|
||||
dataroomName = "Example Data Room",
|
||||
documents = [
|
||||
{ documentName: "Document A" },
|
||||
{ documentName: "Document B" },
|
||||
{ documentName: "Document C" },
|
||||
],
|
||||
senderEmail = "example@example.com",
|
||||
url = "https://app.papermark.com/datarooms/123",
|
||||
preferencesUrl = "https://app.papermark.com/notification-preferences?token=abc",
|
||||
frequency = "daily",
|
||||
}: {
|
||||
dataroomName: string;
|
||||
documents: DocumentChange[];
|
||||
senderEmail: string;
|
||||
url: string;
|
||||
preferencesUrl: string;
|
||||
frequency: "daily" | "weekly";
|
||||
}) {
|
||||
const count = documents.length;
|
||||
const periodLabel = frequency === "daily" ? "today" : "this week";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`}
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] p-5">
|
||||
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
|
||||
<span className="font-bold tracking-tighter">Papermark</span>
|
||||
</Text>
|
||||
<Text className="font-semibold mb-8 mt-4 text-center text-xl">
|
||||
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName}`}
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
The following document{count !== 1 ? "s have" : " has"} been added
|
||||
to <span className="font-semibold">{dataroomName}</span>{" "}
|
||||
{periodLabel}:
|
||||
</Text>
|
||||
<Section className="my-4">
|
||||
{documents.map((doc, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
className="my-1 text-sm leading-6 text-black"
|
||||
>
|
||||
• <span className="font-semibold">{doc.documentName}</span>
|
||||
</Text>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="my-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={url}
|
||||
style={{ padding: "12px 20px" }}
|
||||
>
|
||||
View the dataroom
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-sm text-black">
|
||||
or copy and paste this URL into your browser: <br />
|
||||
{url}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400">Papermark</Text>
|
||||
|
||||
<Hr />
|
||||
<Section className="text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
You received this {frequency} digest from{" "}
|
||||
<span className="font-semibold">{senderEmail}</span> because you
|
||||
viewed the dataroom{" "}
|
||||
<span className="font-semibold">{dataroomName}</span> on
|
||||
Papermark. If you have any feedback or questions about this
|
||||
email, simply reply to it.{" "}
|
||||
<a
|
||||
href={preferencesUrl}
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
Manage your notification preferences
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -72,13 +72,12 @@ export default function DataroomNotification({
|
||||
viewed the dataroom{" "}
|
||||
<span className="font-semibold">{dataroomName}</span> on
|
||||
Papermark. If you have any feedback or questions about this
|
||||
email, simply reply to it. To unsubscribe from updates about
|
||||
this dataroom,{" "}
|
||||
email, simply reply to it.{" "}
|
||||
<a
|
||||
href={unsubscribeUrl}
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
click here
|
||||
Manage your notification preferences
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export const BLOCKED_PATHNAMES = [
|
||||
];
|
||||
|
||||
// list of paths that should be excluded from team checks
|
||||
export const EXCLUDED_PATHS = ["/", "/register", "/privacy", "/view"];
|
||||
export const EXCLUDED_PATHS = ["/", "/register", "/privacy", "/view", "/notification-preferences"];
|
||||
|
||||
// free limits
|
||||
export const LIMITS = {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import {
|
||||
DigestBatch,
|
||||
popDigestQueue,
|
||||
} from "@/lib/redis/dataroom-notification-queue";
|
||||
import { log } from "@/lib/utils";
|
||||
import { generateUnsubscribeUrl } from "@/lib/utils/unsubscribe";
|
||||
|
||||
import { sendDataroomDigestNotification } from "./send-dataroom-digest-notification";
|
||||
|
||||
export async function processDataroomDigest(frequency: "daily" | "weekly") {
|
||||
const batches = await popDigestQueue(frequency);
|
||||
|
||||
if (batches.length === 0) {
|
||||
return { processed: 0 };
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
|
||||
for (const batch of batches) {
|
||||
try {
|
||||
await processBatch(batch, frequency);
|
||||
processed++;
|
||||
} catch (error) {
|
||||
await log({
|
||||
message: `Failed to process ${frequency} digest for viewer ${batch.viewerId} in dataroom ${batch.dataroomId}. Error: ${(error as Error).message}`,
|
||||
type: "error",
|
||||
mention: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { processed };
|
||||
}
|
||||
|
||||
async function processBatch(batch: DigestBatch, frequency: "daily" | "weekly") {
|
||||
if (batch.items.length === 0) return;
|
||||
|
||||
const [viewer, dataroom, senderUser] = await Promise.all([
|
||||
prisma.viewer.findUnique({
|
||||
where: { id: batch.viewerId, teamId: batch.teamId },
|
||||
select: {
|
||||
email: true,
|
||||
views: {
|
||||
where: {
|
||||
dataroomId: batch.dataroomId,
|
||||
viewType: "DATAROOM_VIEW",
|
||||
verified: true,
|
||||
},
|
||||
orderBy: { viewedAt: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
link: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
domainId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.dataroom.findUnique({
|
||||
where: { id: batch.dataroomId, teamId: batch.teamId },
|
||||
select: { name: true },
|
||||
}),
|
||||
batch.items[0]?.senderUserId
|
||||
? prisma.user.findUnique({
|
||||
where: { id: batch.items[0].senderUserId },
|
||||
select: { email: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (!viewer?.email) return;
|
||||
|
||||
const uniqueDocIds = [
|
||||
...new Set(batch.items.map((item) => item.dataroomDocumentId)),
|
||||
];
|
||||
|
||||
const dataroomDocuments = await prisma.dataroomDocument.findMany({
|
||||
where: { id: { in: uniqueDocIds } },
|
||||
select: {
|
||||
id: true,
|
||||
document: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const docNameMap = new Map(
|
||||
dataroomDocuments.map((dd) => [dd.id, dd.document?.name ?? "Untitled"]),
|
||||
);
|
||||
|
||||
const documents = uniqueDocIds.map((id) => ({
|
||||
documentName: docNameMap.get(id) ?? "Untitled",
|
||||
}));
|
||||
|
||||
const link = viewer.views[0]?.link;
|
||||
let linkUrl: string | undefined;
|
||||
if (link?.domainId && link.domainSlug && link.slug) {
|
||||
linkUrl = `https://${link.domainSlug}/${link.slug}`;
|
||||
} else if (link) {
|
||||
linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;
|
||||
}
|
||||
|
||||
if (!linkUrl) return;
|
||||
|
||||
const preferencesUrl = generateUnsubscribeUrl({
|
||||
viewerId: batch.viewerId,
|
||||
dataroomId: batch.dataroomId,
|
||||
teamId: batch.teamId,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendDataroomDigestNotification({
|
||||
dataroomName: dataroom?.name ?? "Unknown Dataroom",
|
||||
documents,
|
||||
senderEmail: senderUser?.email ?? "noreply@papermark.com",
|
||||
to: viewer.email,
|
||||
url: linkUrl,
|
||||
preferencesUrl,
|
||||
frequency,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to send ${frequency} digest for dataroom "${dataroom?.name}" ` +
|
||||
`(viewerId: ${batch.viewerId}, dataroomId: ${batch.dataroomId}): ` +
|
||||
`${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import DataroomDigestNotification from "@/components/emails/dataroom-digest-notification";
|
||||
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
|
||||
export const sendDataroomDigestNotification = async ({
|
||||
dataroomName,
|
||||
documents,
|
||||
senderEmail,
|
||||
to,
|
||||
url,
|
||||
preferencesUrl,
|
||||
frequency,
|
||||
}: {
|
||||
dataroomName: string;
|
||||
documents: { documentName: string }[];
|
||||
senderEmail: string;
|
||||
to: string;
|
||||
url: string;
|
||||
preferencesUrl: string;
|
||||
frequency: "daily" | "weekly";
|
||||
}) => {
|
||||
const count = documents.length;
|
||||
const periodLabel = frequency === "daily" ? "today" : "this week";
|
||||
|
||||
try {
|
||||
await sendEmail({
|
||||
to,
|
||||
subject: `${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`,
|
||||
react: DataroomDigestNotification({
|
||||
senderEmail,
|
||||
dataroomName,
|
||||
documents,
|
||||
url,
|
||||
preferencesUrl,
|
||||
frequency,
|
||||
}),
|
||||
test: process.env.NODE_ENV === "development",
|
||||
system: true,
|
||||
unsubscribeUrl: preferencesUrl,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const ITEM_TTL_SECONDS = 8 * 24 * 60 * 60; // 8 days
|
||||
|
||||
type QueueItem = {
|
||||
dataroomDocumentId: string;
|
||||
senderUserId: string;
|
||||
queuedAt: number;
|
||||
};
|
||||
|
||||
type DigestViewerEntry = {
|
||||
viewerId: string;
|
||||
dataroomId: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
function itemsKey(viewerId: string, dataroomId: string) {
|
||||
return `dataroom_digest_items:${viewerId}:${dataroomId}`;
|
||||
}
|
||||
|
||||
function viewerSetKey(frequency: "daily" | "weekly") {
|
||||
return `dataroom_digest_viewers:${frequency}`;
|
||||
}
|
||||
|
||||
function encodeViewerEntry(entry: DigestViewerEntry): string {
|
||||
return `${entry.viewerId}:${entry.dataroomId}:${entry.teamId}`;
|
||||
}
|
||||
|
||||
function decodeViewerEntry(encoded: string): DigestViewerEntry {
|
||||
const [viewerId, dataroomId, teamId] = encoded.split(":");
|
||||
return { viewerId, dataroomId, teamId };
|
||||
}
|
||||
|
||||
export async function queueNotification({
|
||||
frequency,
|
||||
viewerId,
|
||||
dataroomId,
|
||||
teamId,
|
||||
dataroomDocumentId,
|
||||
senderUserId,
|
||||
}: {
|
||||
frequency: "daily" | "weekly";
|
||||
viewerId: string;
|
||||
dataroomId: string;
|
||||
teamId: string;
|
||||
dataroomDocumentId: string;
|
||||
senderUserId: string;
|
||||
}) {
|
||||
const key = itemsKey(viewerId, dataroomId);
|
||||
const item: QueueItem = {
|
||||
dataroomDocumentId,
|
||||
senderUserId,
|
||||
queuedAt: Date.now(),
|
||||
};
|
||||
|
||||
const pipeline = redis.pipeline();
|
||||
pipeline.rpush(key, JSON.stringify(item));
|
||||
pipeline.expire(key, ITEM_TTL_SECONDS);
|
||||
pipeline.sadd(
|
||||
viewerSetKey(frequency),
|
||||
encodeViewerEntry({ viewerId, dataroomId, teamId }),
|
||||
);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
export type DigestBatch = {
|
||||
viewerId: string;
|
||||
dataroomId: string;
|
||||
teamId: string;
|
||||
items: QueueItem[];
|
||||
};
|
||||
|
||||
export async function popDigestQueue(
|
||||
frequency: "daily" | "weekly",
|
||||
): Promise<DigestBatch[]> {
|
||||
const setKey = viewerSetKey(frequency);
|
||||
|
||||
const members = await redis.smembers(setKey);
|
||||
if (!members || members.length === 0) return [];
|
||||
|
||||
// Remove the entire set atomically
|
||||
await redis.del(setKey);
|
||||
|
||||
const batches: DigestBatch[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const entry = decodeViewerEntry(member);
|
||||
const key = itemsKey(entry.viewerId, entry.dataroomId);
|
||||
|
||||
// Get all items then delete the list
|
||||
const rawItems = await redis.lrange(key, 0, -1);
|
||||
await redis.del(key);
|
||||
|
||||
if (!rawItems || rawItems.length === 0) continue;
|
||||
|
||||
const items: QueueItem[] = rawItems
|
||||
.map((raw) => {
|
||||
try {
|
||||
return typeof raw === "string"
|
||||
? (JSON.parse(raw) as QueueItem)
|
||||
: (raw as QueueItem);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[dataroom-digest] Skipping corrupted queue item for viewer=${entry.viewerId} dataroom=${entry.dataroomId}:`,
|
||||
{ raw, error },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item): item is QueueItem => item !== null);
|
||||
|
||||
batches.push({
|
||||
viewerId: entry.viewerId,
|
||||
dataroomId: entry.dataroomId,
|
||||
teamId: entry.teamId,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
+6
-1
@@ -69,7 +69,12 @@ export const sendEmail = async ({
|
||||
text: plainText,
|
||||
headers: {
|
||||
"X-Entity-Ref-ID": nanoid(),
|
||||
...(unsubscribeUrl ? { "List-Unsubscribe": unsubscribeUrl } : {}),
|
||||
...(unsubscribeUrl
|
||||
? {
|
||||
"List-Unsubscribe": `<${unsubscribeUrl}>`,
|
||||
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { logger, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { queueNotification } from "@/lib/redis/dataroom-notification-queue";
|
||||
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
|
||||
|
||||
type NotificationPayload = {
|
||||
@@ -14,7 +15,6 @@ export const sendDataroomChangeNotificationTask = task({
|
||||
id: "send-dataroom-change-notification",
|
||||
retry: { maxAttempts: 3 },
|
||||
run: async (payload: NotificationPayload) => {
|
||||
// Get all verified viewers for this dataroom
|
||||
const viewers = await prisma.viewer.findMany({
|
||||
where: {
|
||||
teamId: payload.teamId,
|
||||
@@ -62,13 +62,11 @@ export const sendDataroomChangeNotificationTask = task({
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct simplified viewer objects with email and link info, excluding expired/archived links
|
||||
const viewersWithLinks = viewers
|
||||
.map((viewer) => {
|
||||
const view = viewer.views[0];
|
||||
const link = view?.link;
|
||||
|
||||
// Skip if link is expired or archived
|
||||
if (
|
||||
!link ||
|
||||
link.isArchived ||
|
||||
@@ -77,11 +75,11 @@ export const sendDataroomChangeNotificationTask = task({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip if notifications are disabled for this dataroom
|
||||
const parsedPreferences =
|
||||
ZViewerNotificationPreferencesSchema.safeParse(
|
||||
viewer.notificationPreferences,
|
||||
);
|
||||
|
||||
if (
|
||||
parsedPreferences.success &&
|
||||
parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false
|
||||
@@ -89,6 +87,12 @@ export const sendDataroomChangeNotificationTask = task({
|
||||
return null;
|
||||
}
|
||||
|
||||
const frequency =
|
||||
parsedPreferences.success
|
||||
? (parsedPreferences.data.dataroom[payload.dataroomId]?.frequency ??
|
||||
"instant")
|
||||
: "instant";
|
||||
|
||||
let linkUrl = "";
|
||||
if (link.domainId && link.domainSlug && link.slug) {
|
||||
linkUrl = `https://${link.domainSlug}/${link.slug}`;
|
||||
@@ -99,19 +103,42 @@ export const sendDataroomChangeNotificationTask = task({
|
||||
return {
|
||||
id: viewer.id,
|
||||
linkUrl,
|
||||
frequency,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(viewer): viewer is { id: string; linkUrl: string } => viewer !== null,
|
||||
(
|
||||
viewer,
|
||||
): viewer is {
|
||||
id: string;
|
||||
linkUrl: string;
|
||||
frequency: "instant" | "daily" | "weekly";
|
||||
} => viewer !== null,
|
||||
);
|
||||
|
||||
logger.info("Processed viewer links", {
|
||||
viewerCount: viewersWithLinks.length,
|
||||
});
|
||||
|
||||
// Send notification to each viewer
|
||||
for (const viewer of viewersWithLinks) {
|
||||
try {
|
||||
if (viewer.frequency === "daily" || viewer.frequency === "weekly") {
|
||||
await queueNotification({
|
||||
frequency: viewer.frequency,
|
||||
viewerId: viewer.id,
|
||||
dataroomId: payload.dataroomId,
|
||||
teamId: payload.teamId,
|
||||
dataroomDocumentId: payload.dataroomDocumentId,
|
||||
senderUserId: payload.senderUserId,
|
||||
});
|
||||
|
||||
logger.info("Queued notification for digest", {
|
||||
viewerId: viewer.id,
|
||||
frequency: viewer.frequency,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-dataroom-new-document-notification`,
|
||||
{
|
||||
|
||||
@@ -11,16 +11,18 @@ type UnsubscribePayload = {
|
||||
};
|
||||
|
||||
export function generateUnsubscribeUrl(payload: UnsubscribePayload): string {
|
||||
// Add expiration of 3 months
|
||||
const tokenPayload = {
|
||||
...payload,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90,
|
||||
};
|
||||
|
||||
const token = jwt.sign(tokenPayload, JWT_SECRET);
|
||||
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/${
|
||||
payload.dataroomId ? "dataroom" : "yir"
|
||||
}?token=${token}`;
|
||||
|
||||
if (payload.dataroomId) {
|
||||
return `${UNSUBSCRIBE_BASE_URL}/api/notification-preferences/dataroom?token=${token}`;
|
||||
}
|
||||
|
||||
return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/yir?token=${token}`;
|
||||
}
|
||||
|
||||
export function verifyUnsubscribeToken(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NotificationFrequency = z.enum(["instant", "daily", "weekly"]);
|
||||
export type NotificationFrequency = z.infer<typeof NotificationFrequency>;
|
||||
|
||||
export const ZViewerNotificationPreferencesSchema = z
|
||||
.object({
|
||||
dataroom: z.record(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: NotificationFrequency.optional().default("instant"),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) {
|
||||
!path.startsWith("/view/") &&
|
||||
!path.startsWith("/verify") &&
|
||||
!path.startsWith("/unsubscribe") &&
|
||||
!path.startsWith("/notification-preferences") &&
|
||||
!path.startsWith("/auth/email")
|
||||
) {
|
||||
return AppMiddleware(req);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
|
||||
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
|
||||
|
||||
const UpdatePreferencesSchema = z.object({
|
||||
frequency: z.enum(["instant", "daily", "weekly", "disabled"]),
|
||||
});
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET" && req.method !== "POST") {
|
||||
res.status(405).json({ message: "Method Not Allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { token } = req.query as { token: string };
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ message: "Token is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = verifyUnsubscribeToken(token);
|
||||
|
||||
if (!payload) {
|
||||
res.status(400).json({ message: "Invalid or expired token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
res.status(400).json({ message: "Token expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewerId, dataroomId, teamId } = payload;
|
||||
|
||||
if (!dataroomId) {
|
||||
res.status(400).json({ message: "Dataroom ID is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
return res.redirect(`/notification-preferences?token=${token}`);
|
||||
}
|
||||
|
||||
// POST: update preferences
|
||||
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(
|
||||
`notification_prefs_${ipAddress}`,
|
||||
);
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
// RFC 8058 one-click unsubscribe: email clients POST with
|
||||
// body "List-Unsubscribe=One-Click" (form-urlencoded)
|
||||
const isOneClick =
|
||||
req.body?.["List-Unsubscribe"] === "One-Click" ||
|
||||
(typeof req.body === "string" &&
|
||||
req.body.includes("List-Unsubscribe=One-Click"));
|
||||
|
||||
try {
|
||||
const frequency = isOneClick
|
||||
? "disabled"
|
||||
: UpdatePreferencesSchema.parse(req.body).frequency;
|
||||
|
||||
const viewer = await prisma.viewer.findUnique({
|
||||
where: { id: viewerId, teamId },
|
||||
select: { notificationPreferences: true },
|
||||
});
|
||||
|
||||
if (!viewer) {
|
||||
return res.status(404).json({ message: "Viewer not found" });
|
||||
}
|
||||
|
||||
const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(
|
||||
viewer.notificationPreferences,
|
||||
);
|
||||
|
||||
const rawPrefs =
|
||||
typeof viewer.notificationPreferences === "object" &&
|
||||
viewer.notificationPreferences !== null
|
||||
? (viewer.notificationPreferences as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const base = {
|
||||
...rawPrefs,
|
||||
...(parsedPreferences.success ? parsedPreferences.data : {}),
|
||||
};
|
||||
|
||||
const isDisabled = frequency === "disabled";
|
||||
const updatedPreferences = {
|
||||
...base,
|
||||
dataroom: {
|
||||
...(base.dataroom && typeof base.dataroom === "object"
|
||||
? base.dataroom
|
||||
: {}),
|
||||
[dataroomId]: {
|
||||
enabled: !isDisabled,
|
||||
frequency: isDisabled ? "instant" : frequency,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.viewer.update({
|
||||
where: { id: viewerId, teamId },
|
||||
data: { notificationPreferences: updatedPreferences },
|
||||
});
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "Notification preferences updated successfully." });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ message: "Invalid request body", errors: error.errors });
|
||||
}
|
||||
console.error("Failed to update notification preferences:", error);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,7 @@ export default async function handle(
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
// For GET requests, redirect to the unsubscribe page
|
||||
return res.redirect(`/unsubscribe?type=dataroom&token=${token}`);
|
||||
return res.redirect(`/notification-preferences?token=${token}`);
|
||||
}
|
||||
|
||||
// Rate limit the unsubscribe request
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
BellOffIcon,
|
||||
BellRingIcon,
|
||||
CalendarClockIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import PapermarkLogo from "@/public/_static/papermark-logo.svg";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { verifyUnsubscribeToken } from "@/lib/utils/unsubscribe";
|
||||
import { ZViewerNotificationPreferencesSchema } from "@/lib/zod/schemas/notifications";
|
||||
|
||||
type FrequencyOption = "instant" | "daily" | "weekly" | "disabled";
|
||||
|
||||
const FREQUENCY_OPTIONS: {
|
||||
value: FrequencyOption;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof BellRingIcon;
|
||||
}[] = [
|
||||
{
|
||||
value: "instant",
|
||||
label: "Every update",
|
||||
description: "Get notified immediately when new documents are added",
|
||||
icon: BellRingIcon,
|
||||
},
|
||||
{
|
||||
value: "daily",
|
||||
label: "Daily digest",
|
||||
description: "Receive a summary of changes once per day at 9 AM UTC",
|
||||
icon: ClockIcon,
|
||||
},
|
||||
{
|
||||
value: "weekly",
|
||||
label: "Weekly digest",
|
||||
description: "Receive a summary of changes every Monday at 9 AM UTC",
|
||||
icon: CalendarClockIcon,
|
||||
},
|
||||
{
|
||||
value: "disabled",
|
||||
label: "Unsubscribed",
|
||||
description: "Stop receiving notifications for this dataroom",
|
||||
icon: BellOffIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const token = context.query.token as string | undefined;
|
||||
|
||||
if (!token) {
|
||||
return { props: { error: "Token is required", token: "", data: null } };
|
||||
}
|
||||
|
||||
const payload = verifyUnsubscribeToken(token);
|
||||
if (!payload || !payload.dataroomId) {
|
||||
return {
|
||||
props: { error: "Invalid or expired token", token, data: null },
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
return { props: { error: "Token expired", token, data: null } };
|
||||
}
|
||||
|
||||
const { viewerId, dataroomId, teamId } = payload;
|
||||
|
||||
try {
|
||||
const [viewer, dataroom] = await Promise.all([
|
||||
prisma.viewer.findUnique({
|
||||
where: { id: viewerId, teamId },
|
||||
select: { notificationPreferences: true },
|
||||
}),
|
||||
prisma.dataroom.findUnique({
|
||||
where: { id: dataroomId, teamId },
|
||||
select: { name: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!viewer) {
|
||||
return { props: { error: "Viewer not found", token, data: null } };
|
||||
}
|
||||
|
||||
const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(
|
||||
viewer.notificationPreferences,
|
||||
);
|
||||
|
||||
const dataroomPrefs = parsedPreferences.success
|
||||
? parsedPreferences.data.dataroom[dataroomId]
|
||||
: undefined;
|
||||
|
||||
let currentFrequency: FrequencyOption;
|
||||
if (dataroomPrefs?.enabled === false) {
|
||||
currentFrequency = "disabled";
|
||||
} else {
|
||||
currentFrequency = dataroomPrefs?.frequency ?? "instant";
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
error: null,
|
||||
token,
|
||||
data: {
|
||||
dataroomName: dataroom?.name ?? "Unknown Dataroom",
|
||||
currentFrequency,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: { error: "Failed to load preferences", token, data: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationPreferencesPage({
|
||||
error: serverError,
|
||||
token,
|
||||
data,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selected, setSelected] = useState<FrequencyOption>(
|
||||
data?.currentFrequency ?? "instant",
|
||||
);
|
||||
const [status, setStatus] = useState<"idle" | "success" | "error">(
|
||||
serverError ? "error" : "idle",
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState(serverError ?? "");
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/notification-preferences/dataroom?token=${token}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ frequency: selected }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.message || "Failed to save preferences");
|
||||
}
|
||||
setStatus("success");
|
||||
} catch (err) {
|
||||
setErrorMsg((err as Error).message);
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [token, selected]);
|
||||
|
||||
const hasChanged = data ? selected !== data?.currentFrequency : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Notification Preferences | Papermark</title>
|
||||
</Head>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50">
|
||||
<header className="px-6 py-5">
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={PapermarkLogo}
|
||||
width={119}
|
||||
height={32}
|
||||
alt="Papermark"
|
||||
/>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 items-start justify-center px-4 pt-[calc(10vh)]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-white shadow-sm">
|
||||
<div className="px-6 pb-4 pt-6">
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
Notification Preferences
|
||||
</h1>
|
||||
{data ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose how often you want to hear about updates to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{data.dataroomName}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6">
|
||||
{status === "error" && !data ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XIcon className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Something went wrong
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{errorMsg}
|
||||
</p>
|
||||
</div>
|
||||
) : status === "success" ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="py-12 text-center"
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100">
|
||||
<CheckIcon className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Preferences saved
|
||||
</p>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm text-muted-foreground">
|
||||
{selected === "disabled"
|
||||
? "You've unsubscribed from notifications for this dataroom."
|
||||
: `You'll receive ${selected === "instant" ? "instant" : `${selected} digest`} notifications.`}
|
||||
</p>
|
||||
<p className="mt-6 text-xs text-muted-foreground/60">
|
||||
You can close this window now.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{FREQUENCY_OPTIONS.map((option) => {
|
||||
const isSelected = selected === option.value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setSelected(option.value);
|
||||
setStatus("idle");
|
||||
}}
|
||||
className={`group flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-foreground bg-foreground/[0.03]"
|
||||
: "border-border bg-white hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isSelected
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isSelected
|
||||
? "text-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border transition-colors ${
|
||||
isSelected
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/40 bg-white"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckIcon className="h-2.5 w-2.5 text-white" />
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{status === "error" && data ? (
|
||||
<p className="mt-3 text-center text-sm text-destructive">
|
||||
{errorMsg}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanged}
|
||||
loading={saving}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
Save preferences
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground/60">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
className="underline underline-offset-2 transition-colors hover:text-muted-foreground"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Papermark
|
||||
</a>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user