Merge pull request #2093 from mfts/feat/dataroom-visitor-notifications

feat: add notification settings for visitors
This commit is contained in:
Marc Seitz
2026-03-02 17:27:44 +11:00
committed by GitHub
16 changed files with 1003 additions and 17 deletions
@@ -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>
);
}
+2 -3
View File
@@ -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
View File
@@ -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 = {
+132
View File
@@ -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;
}
};
+121
View File
@@ -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
View File
@@ -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",
}
: {}),
},
});
+33 -6
View File
@@ -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`,
{
+6 -4
View File
@@ -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(
+4
View File
@@ -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"),
}),
),
})
+1
View File
@@ -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" });
}
}
+1 -2
View File
@@ -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
+337
View File
@@ -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>
</>
);
}