mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 02:27:00 +00:00
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
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);
|
||
|
||
// We’ll 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);
|
||
}
|
||
}
|