Skip to content

Commit a0c34ff

Browse files
rajat1saxenaRajat Saxena
andauthored
Basic link clicks tracking (#602)
Co-authored-by: Rajat Saxena <hi@rajatsaxena.dev>
1 parent 985b673 commit a0c34ff

File tree

6 files changed

+934
-578
lines changed

6 files changed

+934
-578
lines changed

apps/queue/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
"@courselit/common-models": "workspace:^",
1717
"@courselit/email-editor": "workspace:^",
1818
"@courselit/utils": "workspace:^",
19+
"@types/jsdom": "^21.1.7",
1920
"bullmq": "^4.14.0",
2021
"express": "^4.18.2",
22+
"jsdom": "^26.1.0",
2123
"liquidjs": "^10.11.1",
2224
"mongodb": "^6.15.0",
2325
"mongoose": "^8.13.1",

apps/queue/src/domain/process-ongoing-sequences.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Email as EmailType, renderEmailToHtml } from "@courselit/email-editor";
2525
import { getUnsubLink } from "../utils/get-unsub-link";
2626
import { getSiteUrl } from "../utils/get-site-url";
2727
import { jwtUtils } from "@courselit/utils";
28+
import { JSDOM } from "jsdom";
2829
const liquidEngine = new Liquid();
2930

3031
new Worker(
@@ -212,7 +213,7 @@ async function attemptMailSending({
212213
process.env.PIXEL_SIGNING_SECRET,
213214
"365d",
214215
);
215-
const pixelUrl = `${getSiteUrl(domain)}/api/pixel?d=${pixelToken}`;
216+
const pixelUrl = `${getSiteUrl(domain)}/api/track/open?d=${pixelToken}`;
216217
const emailContentWithPixel: EmailType = {
217218
content: [
218219
...email.content.content,
@@ -236,12 +237,21 @@ async function attemptMailSending({
236237
}),
237238
templatePayload,
238239
);
240+
241+
const contentWithTrackedLinks = transformLinksForClickTracking(
242+
content,
243+
user.userId,
244+
ongoingSequence.sequenceId,
245+
email.emailId,
246+
domain,
247+
);
248+
239249
try {
240250
await sendMail({
241251
from,
242252
to,
243253
subject,
244-
html: content,
254+
html: contentWithTrackedLinks,
245255
});
246256
// @ts-ignore - Mongoose type compatibility issue
247257
await EmailDelivery.create({
@@ -265,3 +275,56 @@ async function attemptMailSending({
265275
throw err;
266276
}
267277
}
278+
279+
function transformLinksForClickTracking(
280+
htmlContent: string,
281+
userId: string,
282+
sequenceId: string,
283+
emailId: string,
284+
domain: Domain,
285+
): string {
286+
try {
287+
const dom = new JSDOM(htmlContent);
288+
const document = dom.window.document;
289+
290+
const links = document.querySelectorAll("a");
291+
292+
links.forEach((link, index) => {
293+
const originalUrl = link.getAttribute("href");
294+
295+
if (!originalUrl) return;
296+
297+
if (
298+
originalUrl.includes("/api/track") ||
299+
originalUrl.includes("/api/unsubscribe") ||
300+
originalUrl.startsWith("mailto:") ||
301+
originalUrl.startsWith("tel:") ||
302+
originalUrl.startsWith("#")
303+
) {
304+
return;
305+
}
306+
307+
const linkPayload = {
308+
userId,
309+
sequenceId,
310+
emailId,
311+
index,
312+
link: encodeURIComponent(originalUrl),
313+
};
314+
315+
const linkToken = jwtUtils.generateToken(
316+
linkPayload,
317+
process.env.PIXEL_SIGNING_SECRET,
318+
"365d",
319+
);
320+
const trackingUrl = `${getSiteUrl(domain)}/api/track/click?d=${linkToken}`;
321+
322+
link.setAttribute("href", trackingUrl);
323+
});
324+
325+
return dom.serialize();
326+
} catch (error) {
327+
logger.error("Error transforming links with jsdom:", error);
328+
return htmlContent;
329+
}
330+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import DomainModel, { Domain } from "@models/Domain";
3+
import EmailEventModel from "@models/EmailEvent";
4+
import UserModel from "@models/User";
5+
import SequenceModel from "@models/Sequence";
6+
import { Constants, Sequence, User } from "@courselit/common-models";
7+
import { error } from "@/services/logger";
8+
import { jwtUtils } from "@courselit/utils";
9+
10+
export const dynamic = "force-dynamic";
11+
12+
function getJwtSecret(): string {
13+
const jwtSecret = process.env.PIXEL_SIGNING_SECRET;
14+
if (!jwtSecret) {
15+
throw new Error("PIXEL_SIGNING_SECRET is not defined");
16+
}
17+
return jwtSecret;
18+
}
19+
20+
export async function GET(req: NextRequest) {
21+
if (!process.env.PIXEL_SIGNING_SECRET) {
22+
error(
23+
"PIXEL_SIGNING_SECRET environment variable is not defined. No click tracking is done.",
24+
);
25+
return NextResponse.redirect(new URL("/", req.url));
26+
}
27+
28+
try {
29+
const domainName = req.headers.get("domain");
30+
const domain = await DomainModel.findOne<Domain>({
31+
name: domainName,
32+
});
33+
if (!domain) {
34+
error(`Domain not found: ${domainName}`);
35+
return NextResponse.redirect(new URL("/", req.url));
36+
}
37+
38+
const { searchParams } = new URL(req.url);
39+
const d = searchParams.get("d");
40+
if (!d) {
41+
error("Missing data query parameter");
42+
return NextResponse.redirect(new URL("/", req.url));
43+
}
44+
45+
const jwtSecret = getJwtSecret();
46+
const payload = jwtUtils.verifyToken(d, jwtSecret);
47+
const { userId, sequenceId, emailId, link, index } = payload as any;
48+
49+
if (!userId || !sequenceId || !emailId || !link) {
50+
error(`Invalid payload: Not all required fields are present`, {
51+
payload,
52+
});
53+
return NextResponse.redirect(new URL("/", req.url));
54+
}
55+
56+
const sequence = await SequenceModel.findOne<Sequence>({
57+
domain: domain._id,
58+
sequenceId,
59+
});
60+
const user = await UserModel.findOne<User>({
61+
domain: domain._id,
62+
userId,
63+
});
64+
const email = sequence?.emails.find((e) => e.emailId === emailId);
65+
66+
if (sequence && user && email) {
67+
await EmailEventModel.create({
68+
domain: domain._id,
69+
sequenceId,
70+
userId,
71+
emailId,
72+
action: Constants.EmailEventAction.CLICK,
73+
link,
74+
linkIndex: index,
75+
});
76+
}
77+
78+
// Redirect to the original URL
79+
const decodedUrl = decodeURIComponent(link);
80+
return NextResponse.redirect(decodedUrl);
81+
} catch (err) {
82+
error(`Invalid click data`, {
83+
fileName: "click.route.ts",
84+
stack: err.stack,
85+
});
86+
// Always redirect to home page on error
87+
return NextResponse.redirect(new URL("/", req.url));
88+
}
89+
}

packages/common-logic/src/models/email-event.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export const EmailEventSchema = new mongoose.Schema(
1212
required: true,
1313
enum: Object.values(Constants.EmailEventAction),
1414
},
15-
linkId: { type: String },
15+
link: { type: String },
16+
linkIndex: { type: Number },
1617
bounceType: { type: String, enum: ["hard", "soft"] },
1718
bounceReason: { type: String },
1819
},

0 commit comments

Comments
 (0)