Skip to content

feat: new link tracking #1542

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
953 changes: 953 additions & 0 deletions app/api/views-dataroom/route.ts

Large diffs are not rendered by default.

639 changes: 639 additions & 0 deletions app/api/views/route.ts

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion components/emails/viewed-dataroom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ import {
export default function ViewedDataroom({
dataroomId = "123",
dataroomName = "Example Dataroom",
linkName = "Dataroom",
viewerEmail,
locationString,
}: {
dataroomId: string;
dataroomName: string;
linkName: string;
viewerEmail: string | null;
locationString?: string;
}) {
return (
<Html>
Expand All @@ -42,7 +46,13 @@ export default function ViewedDataroom({
<span className="font-semibold">
{viewerEmail ? `${viewerEmail}` : `someone`}
</span>
.
{locationString ? (
<span>
{" "}
in <span className="font-semibold">{locationString}</span>
</span>
) : null}{" "}
from the link <span className="font-semibold">{linkName}</span>.
</Text>
<Text className="text-sm leading-6 text-black">
You can get the detailed engagement analytics like time-spent per
Expand Down
10 changes: 9 additions & 1 deletion components/emails/viewed-document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export default function ViewedDocument({
documentName = "Pitchdeck",
linkName = "Pitchdeck",
viewerEmail,
locationString,
}: {
documentId: string;
documentName: string;
linkName: string;
viewerEmail: string | null;
locationString?: string;
}) {
return (
<Html>
Expand All @@ -43,7 +45,13 @@ export default function ViewedDocument({
viewed by{" "}
<span className="font-semibold">
{viewerEmail ? `${viewerEmail}` : `someone`}
</span>{" "}
</span>
{locationString ? (
<span>
{" "}
in <span className="font-semibold">{locationString}</span>
</span>
) : null}{" "}
from the link <span className="font-semibold">{linkName}</span>.
</Text>
<Text className="text-sm leading-6 text-black">
Expand Down
15 changes: 13 additions & 2 deletions lib/api/notification-helper.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { log } from "@/lib/utils";

export default async function sendNotification({ viewId }: { viewId: string }) {
export default async function sendNotification({
viewId,
locationData,
}: {
viewId: string;
locationData: {
continent: string | null;
country: string;
region: string;
city: string;
};
}) {
return await fetch(`${process.env.NEXTAUTH_URL}/api/jobs/send-notification`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
},
body: JSON.stringify({ viewId: viewId }),
body: JSON.stringify({ viewId: viewId, locationData }),
})
.then(() => {})
.catch((error) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,23 @@
import { IncomingHttpHeaders } from "http";

import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { Geo } from "@/lib/types";
import { getDomainWithoutWWW, log } from "@/lib/utils";
import { capitalize } from "@/lib/utils";
import { LOCALHOST_GEO_DATA, getGeoData } from "@/lib/utils/geo";
import { userAgentFromString } from "@/lib/utils/user-agent";
import { log } from "@/lib/utils";
import { sendWebhooks } from "@/lib/webhook/send-webhooks";

interface LinkViewProps {
viewId: string;
linkId: string;
teamId: string;
documentId?: string;
dataroomId?: string;
headers: IncomingHttpHeaders;
}

export async function recordVisit({
viewId,
linkId,
export async function sendLinkViewWebhook({
teamId,
documentId,
dataroomId,
headers,
}: LinkViewProps) {
clickData,
}: {
teamId: string;
clickData: any;
}) {
try {
const {
view_id: viewId,
link_id: linkId,
document_id: documentId,
dataroom_id: dataroomId,
} = clickData;

if (!viewId || !linkId || !teamId) {
throw new Error("Missing required parameters");
}
Expand Down Expand Up @@ -57,13 +48,6 @@ export async function recordVisit({
return;
}

// Get geo data
const geo: Geo =
process.env.VERCEL === "1" ? getGeoData(headers) : LOCALHOST_GEO_DATA;

const referer = headers.referer;
const ua = userAgentFromString(headers["user-agent"]);

// Get link information
const link = await prisma.link.findUnique({
where: { id: linkId, teamId },
Expand Down Expand Up @@ -131,13 +115,13 @@ export async function recordVisit({
viewId: view.id,
email: view.viewerEmail,
emailVerified: view.verified,
country: geo?.country || null,
city: geo?.city || null,
device: ua.device.type ? capitalize(ua.device.type) : "Desktop",
browser: ua.browser.name || null,
os: ua.os.name || null,
ua: ua.ua || null,
referer: referer ? (getDomainWithoutWWW(referer) ?? null) : null,
country: clickData.country,
city: clickData.city,
device: clickData.device,
browser: clickData.browser,
os: clickData.os,
ua: clickData.ua,
referer: clickData.referer,
};

// Get document and dataroom information for webhook in parallel
Expand Down Expand Up @@ -185,11 +169,13 @@ export async function recordVisit({
data: webhookData,
});
}
return;
} catch (error) {
log({
message: `Error sending webhooks for link view: ${error}`,
type: "error",
mention: true,
});
return;
}
}
13 changes: 8 additions & 5 deletions lib/auth/dataroom-auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { NextApiRequest } from "next";
import { cookies } from "next/headers";
import { NextRequest } from "next/server";

import { ipAddress } from "@vercel/functions";
import crypto from "crypto";
import { z } from "zod";

import { redis } from "@/lib/redis";

import { LOCALHOST_IP } from "../utils/geo";
import { getIpAddress } from "../utils/ip";

const COOKIE_EXPIRATION_TIME = 23 * 60 * 60 * 1000; // 23 hours
Expand Down Expand Up @@ -58,13 +61,13 @@ async function createDataroomSession(
}

async function verifyDataroomSession(
req: NextApiRequest,
request: NextRequest,
linkId: string,
dataroomId: string,
): Promise<DataroomSession | null> {
if (!dataroomId) return null;

const sessionToken = req.cookies[`pm_drs_${linkId}`];
const sessionToken = cookies().get(`pm_drs_${linkId}`)?.value;
if (!sessionToken) return null;

const session = await redis.get(`dataroom_session:${sessionToken}`);
Expand All @@ -79,9 +82,9 @@ async function verifyDataroomSession(
return null;
}

const ipAddress = getIpAddress(req.headers);
const ipAddressValue = ipAddress(request) ?? LOCALHOST_IP;

if (ipAddress !== sessionData.ipAddress) {
if (ipAddressValue !== sessionData.ipAddress) {
await redis.del(`dataroom_session:${sessionToken}`);
return null;
}
Expand Down
6 changes: 6 additions & 0 deletions lib/emails/send-viewed-dataroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@ export const sendViewedDataroomEmail = async ({
dataroomId,
dataroomName,
viewerEmail,
linkName,
teamMembers,
locationString,
}: {
ownerEmail: string | null;
dataroomId: string;
dataroomName: string;
viewerEmail: string | null;
linkName: string;
teamMembers?: string[];
locationString?: string;
}) => {
const emailTemplate = ViewedDataroomEmail({
dataroomId,
dataroomName,
viewerEmail,
linkName,
locationString,
});
try {
if (!ownerEmail) {
Expand Down
3 changes: 3 additions & 0 deletions lib/emails/send-viewed-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ export const sendViewedDocumentEmail = async ({
linkName,
viewerEmail,
teamMembers,
locationString,
}: {
ownerEmail: string | null;
documentId: string;
documentName: string;
linkName: string;
viewerEmail: string | null;
teamMembers?: string[];
locationString?: string;
}) => {
const emailTemplate = ViewedDocumentEmail({
documentId,
documentName,
linkName,
viewerEmail,
locationString,
});
try {
if (!ownerEmail) {
Expand Down
1 change: 1 addition & 0 deletions lib/id-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class IdGenerator<TPrefixes extends string> {
export const newId = new IdGenerator({
view: "view",
videoView: "vview",
linkView: "lview",
inv: "inv", // invitation
email: "email",
doc: "doc",
Expand Down
43 changes: 23 additions & 20 deletions lib/middleware/domain.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
import { NextRequest, NextResponse } from "next/server";

import { BLOCKED_PATHNAMES, PAPERMARK_HEADERS } from "@/lib/constants";
import { BLOCKED_PATHNAMES } from "@/lib/constants";

export default async function DomainMiddleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const host = req.headers.get("host");

// clone the URL so we can modify it
const url = req.nextUrl.clone();

// if there's a path and it's not "/" then we need to check if it's a custom domain
if (path !== "/") {
if (BLOCKED_PATHNAMES.includes(path) || path.includes(".")) {
url.pathname = "/404";
return NextResponse.rewrite(url, { status: 404 });
}
// Subdomain available, rewriting
// >>> Rewriting: ${path} to /view/domains/${host}${path}`
url.pathname = `/view/domains/${host}${path}`;
const response = NextResponse.rewrite(url, PAPERMARK_HEADERS);

// Add X-Robots-Tag header for custom domain routes
response.headers.set("X-Robots-Tag", "noindex");
return response;
} else {
// redirect plain custom domain to papermark.io, eventually to it's own landing page
// If it's the root path, redirect to papermark.io/home
if (path === "/") {
return NextResponse.redirect(
new URL("https://www.papermark.io/home", req.url),
);
}

const url = req.nextUrl.clone();

// Check for blocked pathnames
if (BLOCKED_PATHNAMES.includes(path) || path.includes(".")) {
url.pathname = "/404";
return NextResponse.rewrite(url, { status: 404 });
}

// Rewrite the URL to the correct page component for custom domains
// Rewrite to the pages/view/domains/[domain]/[slug] route
url.pathname = `/view/domains/${host}/${path}`;

return NextResponse.rewrite(url, {
headers: {
"X-Robots-Tag": "noindex",
"X-Powered-By":
"Papermark.io - Document sharing infrastructure for the modern web",
},
});
}
37 changes: 37 additions & 0 deletions lib/tinybird/datasources/pm_click_events.datasource
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
VERSION 1

DESCRIPTION >
Event track when a visitor clicks a link

SCHEMA >
`timestamp` DateTime64(3) `json:$.timestamp`,
`click_id` String `json:$.click_id`,
`view_id` String `json:$.view_id`,
`link_id` String `json:$.link_id`,
`document_id` Nullable(String) `json:$.document_id`,
`dataroom_id` Nullable(String) `json:$.dataroom_id`,
`continent` LowCardinality(String) `json:$.continent`,
`country` LowCardinality(String) `json:$.country`,
`city` String `json:$.city`,
`region` String `json:$.region`,
`latitude` String `json:$.latitude`,
`longitude` String `json:$.longitude`,
`device` LowCardinality(String) `json:$.device`,
`device_model` LowCardinality(String) `json:$.device_model`,
`device_vendor` LowCardinality(String) `json:$.device_vendor`,
`browser` LowCardinality(String) `json:$.browser`,
`browser_version` String `json:$.browser_version`,
`os` LowCardinality(String) `json:$.os`,
`os_version` String `json:$.os_version`,
`engine` LowCardinality(String) `json:$.engine`,
`engine_version` String `json:$.engine_version`,
`cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,
`ua` String `json:$.ua`,
`bot` UInt8 `json:$.bot`,
`referer` String `json:$.referer`,
`referer_url` String `json:$.referer_url`,
`ip_address` Nullable(String) `json:$.ip_address`

ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
ENGINE_SORTING_KEY "timestamp, view_id, link_id, click_id"
Loading