Skip to content

Commit

Permalink
feat: add dataroom notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
mfts committed Nov 23, 2024
1 parent 74dd93d commit 7ff9fda
Show file tree
Hide file tree
Showing 21 changed files with 650 additions and 94 deletions.
21 changes: 17 additions & 4 deletions components/emails/dataroom-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@ export default function DataroomNotification({
documentName,
senderEmail,
url,
unsubscribeUrl,
}: {
dataroomName: string;
documentName: string | undefined;
senderEmail: string;
url: string;
unsubscribeUrl: string;
}) {
return (
<Html>
<Head />
<Preview>View dataroom on Papermark</Preview>
<Preview>Dataroom update available</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
Expand All @@ -37,7 +39,6 @@ export default function DataroomNotification({
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
{`New document available for ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">Hey!</Text>
<Text className="text-sm leading-6 text-black">
A new document{" "}
<span className="font-semibold">{documentName}</span> has been
Expand Down Expand Up @@ -71,8 +72,20 @@ export default function DataroomNotification({
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
You received this email 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. To unsubscribe from updates about
this dataroom,{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2 visited:text-gray-400 hover:text-gray-400"
>
click here
</a>
.
</Text>
</Section>
</Container>
Expand Down
2 changes: 1 addition & 1 deletion components/visitors/dataroom-viewers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function DataroomViewersTable({
return (
<div className="w-full">
<div>
<h2 className="mb-2 md:mb-4">All dataroom viewers</h2>
<h2 className="mb-2 md:mb-4">All dataroom visitors</h2>
</div>
<div className="rounded-md border">
<Table>
Expand Down
18 changes: 1 addition & 17 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,7 @@ export const BLOCKED_PATHNAMES = [
];

// list of paths that should be excluded from team checks
export const EXCLUDED_PATHS = [
"/",
"/register",
"/privacy",
"/oss-friends",
"/pricing",
"/docsend-alternatives",
"/launch-week",
"/open-source-investors",
"/investors",
"/ai",
"/share-notion-page",
"/alternatives",
"/investors",
"/blog",
"/view",
];
export const EXCLUDED_PATHS = ["/", "/register", "/privacy", "/view"];

// free limits
export const LIMITS = {
Expand Down
4 changes: 4 additions & 0 deletions lib/emails/send-dataroom-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ export const sendDataroomNotification = async ({
senderEmail,
to,
url,
unsubscribeUrl,
}: {
dataroomName: string;
documentName: string | undefined;
senderEmail: string;
to: string;
url: string;
unsubscribeUrl: string;
}) => {
try {
await sendEmail({
Expand All @@ -24,9 +26,11 @@ export const sendDataroomNotification = async ({
dataroomName,
documentName,
url,
unsubscribeUrl,
}),
test: process.env.NODE_ENV === "development",
system: true,
unsubscribeUrl,
});
} catch (e) {
console.error(e);
Expand Down
3 changes: 3 additions & 0 deletions lib/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const sendEmail = async ({
test,
cc,
scheduledAt,
unsubscribeUrl,
}: {
to: string;
subject: string;
Expand All @@ -29,6 +30,7 @@ export const sendEmail = async ({
test?: boolean;
cc?: string | string[];
scheduledAt?: string;
unsubscribeUrl?: string;
}) => {
if (!resend) {
// Throw an error if resend is not initialized
Expand Down Expand Up @@ -57,6 +59,7 @@ export const sendEmail = async ({
text: plainText,
headers: {
"X-Entity-Ref-ID": nanoid(),
...(unsubscribeUrl ? { "List-Unsubscribe": unsubscribeUrl } : {}),
},
});

Expand Down
162 changes: 162 additions & 0 deletions lib/trigger/dataroom-change-notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { logger, task } from "@trigger.dev/sdk/v3";

import prisma from "@/lib/prisma";

import { ZNotificationPreferencesSchema } from "../types";

type NotificationPayload = {
dataroomId: string;
dataroomDocumentId: string;
senderUserId: string;
teamId: string;
};

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,
views: {
some: {
dataroomId: payload.dataroomId,
viewType: "DATAROOM_VIEW",
verified: true,
},
},
},
select: {
id: true,
notificationPreferences: true,
views: {
where: {
dataroomId: payload.dataroomId,
viewType: "DATAROOM_VIEW",
verified: true,
},
orderBy: {
viewedAt: "desc",
},
take: 1,
include: {
link: {
select: {
id: true,
slug: true,
domainSlug: true,
domainId: true,
isArchived: true,
expiresAt: true,
},
},
},
},
},
});

if (!viewers || viewers.length === 0) {
logger.info("No verified viewers found for this dataroom", {
dataroomId: payload.dataroomId,
});
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 ||
(link.expiresAt && new Date(link.expiresAt) < new Date())
) {
return null;
}

// Skip if notifications are disabled for this dataroom
const parsedPreferences = ZNotificationPreferencesSchema.safeParse(
viewer.notificationPreferences,
);
if (
parsedPreferences.success &&
parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false
) {
return null;
}

let linkUrl = "";
if (link.domainId && link.domainSlug && link.slug) {
linkUrl = `https://${link.domainSlug}/${link.slug}`;
} else {
linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;
}

return {
id: viewer.id,
linkUrl,
};
})
.filter(
(viewer): viewer is { id: string; linkUrl: string } => viewer !== null,
);

logger.info("Processed viewer links", {
viewerCount: viewersWithLinks.length,
});

// Send notification to each viewer
for (const viewer of viewersWithLinks) {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-dataroom-new-document-notification`,
{
method: "POST",
body: JSON.stringify({
dataroomId: payload.dataroomId,
linkUrl: viewer.linkUrl,
dataroomDocumentId: payload.dataroomDocumentId,
viewerId: viewer.id,
senderUserId: payload.senderUserId,
teamId: payload.teamId,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
},
},
);

if (!response.ok) {
logger.error("Failed to send dataroom notification", {
viewerId: viewer.id,
dataroomId: payload.dataroomId,
error: await response.text(),
});
continue;
}

const { message } = (await response.json()) as { message: string };
logger.info("Notification sent successfully", {
viewerId: viewer.id,
message,
});
} catch (error) {
logger.error("Error sending notification", {
viewerId: viewer.id,
error,
});
}
}

logger.info("Completed sending notifications", {
dataroomId: payload.dataroomId,
viewerCount: viewers.length,
});
return;
},
});
11 changes: 11 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,14 @@ 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: {} });
32 changes: 32 additions & 0 deletions lib/utils/unsubscribe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.NEXT_PRIVATE_UNSUBSCRIBE_JWT_SECRET as string;
const UNSUBSCRIBE_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string;

type UnsubscribePayload = {
viewerId: string;
dataroomId: string;
teamId: string;
exp?: number; // Expiration timestamp
};

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/dataroom?token=${token}`;
}

export function verifyUnsubscribeToken(
token: string,
): UnsubscribePayload | null {
try {
return jwt.verify(token, JWT_SECRET) as UnsubscribePayload;
} catch (error) {
return null;
}
}
6 changes: 5 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) {
return DomainMiddleware(req);
}

if (!path.startsWith("/view/") && !path.startsWith("/verify")) {
if (
!path.startsWith("/view/") &&
!path.startsWith("/verify") &&
!path.startsWith("/unsubscribe")
) {
return AppMiddleware(req);
}

Expand Down
9 changes: 9 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ const nextConfig = {
},
],
},
{
source: "/unsubscribe",
headers: [
{
key: "X-Robots-Tag",
value: "noindex",
},
],
},
];
},
experimental: {
Expand Down
Loading

0 comments on commit 7ff9fda

Please sign in to comment.