Files
linkwarden/apps/worker/workers/trialEndEmailWorker.ts
2025-10-14 19:03:49 -04:00

126 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from "@linkwarden/prisma";
import transporter from "@linkwarden/lib/transporter";
import Handlebars from "handlebars";
import { readFileSync } from "fs";
import path from "path";
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
const batchSize = 10;
const pauseMs = 30000;
/**
* Runs the "trial ended" notifier in batches.
* - Only runs if NEXT_PUBLIC_TRIAL_PERIOD_DAYS > 1
* - Picks users whose createdAt is older than that trial window AND trialEndEmailSent=false
* - Skips users with an active own or parent subscription
* - Sends email via the provided transporter
* - Marks trialEndEmailSent=true for processed users (including those with active subs)
* - Waits for a minute between batches
*/
export async function trialEndEmailWorker() {
const trialDays = Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS);
if (
!Number.isFinite(trialDays) ||
trialDays <= 1 ||
!process.env.STRIPE_SECRET_KEY ||
process.env.NEXT_PUBLIC_REQUIRE_CC === "true"
) {
return;
}
console.log("\x1b[34m%s\x1b[0m", `Starting trial-ended email worker...`);
const cutoff = new Date(Date.now() - trialDays * 24 * 60 * 60 * 1000);
const from = {
name: "Linkwarden",
address: String(process.env.EMAIL_FROM),
};
while (true) {
// 1) Pick a batch of candidates
const candidates = await prisma.user.findMany({
where: {
trialEndEmailSent: false,
emailVerified: {
not: null,
},
createdAt: { lte: cutoff, gte: new Date("2025-09-25") }, // safety upper bound to avoid processing old users
},
orderBy: { createdAt: "asc" },
take: batchSize,
include: {
subscriptions: { select: { active: true } }, // own subscription
parentSubscription: { select: { active: true } }, // family/parent plan
},
});
if (candidates.length === 0) {
await sleep(pauseMs);
continue;
}
const processedIds: number[] = [];
let emailsSent = 0;
// 2) Process the batch (send emails if no active sub)
for (const user of candidates) {
const hasActive =
Boolean(user.subscriptions?.active) ||
Boolean(user.parentSubscription?.active);
// Well mark users as processed at the end of this loop iteration.
// If sending fails, we skip marking so we retry next pass.
if (!hasActive && user.email) {
emailsSent++;
try {
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "trialEnded.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
await transporter.sendMail({
from,
to: user.email,
subject: "Your Linkwarden trial has ended",
html: emailTemplate({
name: user.name?.trim() ? user.name.trim() : "there",
url: process.env.BASE_URL,
}),
});
} catch (err) {
console.error(
`[trial-worker] Failed to send trial-ended email to user ${user.id}`,
err
);
// Do not mark as processed so it can be retried on the next batch run
await sleep(pauseMs);
continue;
}
}
// Whether we emailed or skipped (active sub, or missing email), mark processed
processedIds.push(user.id);
}
// 3) Mark processed users so we don't pick them again
if (processedIds.length) {
await prisma.user.updateMany({
where: { id: { in: processedIds } },
data: { trialEndEmailSent: true },
});
console.log(
"\x1b[34m%s\x1b[0m",
`Marked off ${processedIds.length} users' trialEndEmailSent to true. Emails sent: ${emailsSent}`
);
}
// 4) Pause before the next batch
await sleep(pauseMs);
}
}