diff --git a/server/package-lock.json b/server/package-lock.json index 5b0df3fd..5465280c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,6 +18,7 @@ "jsonwebtoken": "^9.0.2", "mongodb-memory-server": "^10.1.0", "mongoose": "^8.3.2", + "node-cron": "^4.2.1", "nodemailer": "^7.0.5" }, "devDependencies": { @@ -6210,7 +6211,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7762,6 +7763,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 12561f22..9eb8521e 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "jsonwebtoken": "^9.0.2", "mongodb-memory-server": "^10.1.0", "mongoose": "^8.3.2", + "node-cron": "^4.2.1", "nodemailer": "^7.0.5" } } diff --git a/server/src/config.js b/server/src/config.js index f376c488..f442c9b2 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -55,6 +55,9 @@ const config = { EMAIL_PASSWORD: getEnvVariable("EMAIL_PASSWORD", null), // Google OAuth configuration CLIENT_ID: getEnvVariable("CLIENT_ID", null), + + IS_TEST_MODE: getEnvVariable("IS_TEST_MODE", "false"), + TEST_EMAIL: getEnvVariable("TEST_EMAIL", null), }; export default config; diff --git a/server/src/cron/weeklyDigestCron.js b/server/src/cron/weeklyDigestCron.js new file mode 100644 index 00000000..1de34ae5 --- /dev/null +++ b/server/src/cron/weeklyDigestCron.js @@ -0,0 +1,58 @@ +import cron from "node-cron"; +import { generateWeeklyDigest } from "../services/weeklyDigest.js"; +import { makeDigestHtml } from "../util/makeDigestHtml.js"; +import { sendWeeklyEmail } from "../util/sendWeeklyEmail.js"; +import config from "../config.js"; + +const { IS_TEST_MODE, TEST_EMAIL } = config; + +// Sleep function +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// cron.schedule("0 10 * * 5", async () => { //every Friday at 10:00 AM (server time). +cron.schedule("*/2 * * * *", async () => { + //will run the task every 2nd minute. + try { + const digests = await generateWeeklyDigest(); + const maxEmailsPerRun = 10; + let sentCount = 0; + + if (IS_TEST_MODE === "true") { + // TEST MODE + if (digests.length > 0) { + const digest = digests[0]; + const html = makeDigestHtml(digest.topPosts); + const subject = "Your Weekly Digest — TEST"; + + await sendWeeklyEmail({ + to: TEST_EMAIL, + subject, + text: "Test: check out the top posts of the week on our website.", + html, + }); + } + } else { + // PROD MODE + for (const digest of digests) { + if (sentCount >= maxEmailsPerRun) { + break; + } + const html = makeDigestHtml(digest.topPosts); + const subject = "Your Weekly Digest — Top 5 Posts"; + + await sendWeeklyEmail({ + to: digest.email, + subject, + text: "Check out the top posts of the week on our website.", + html, + }); + sentCount++; + await sleep(10000); + } + } + } catch (e) { + console.error("Weekly digest cron error:", e); + } +}); diff --git a/server/src/index.js b/server/src/index.js index f567ac4f..0bd2d174 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,4 +1,3 @@ -// index.js import express from "express"; import app from "./app.js"; import { logInfo, logError } from "./util/logging.js"; @@ -7,6 +6,7 @@ import testRouter from "./testRouter.js"; import config from "./config.js"; import path from "path"; import { fileURLToPath } from "url"; +import "./cron/weeklyDigestCron.js"; const { PORT, NODE_ENV } = config; diff --git a/server/src/services/weeklyDigest.js b/server/src/services/weeklyDigest.js new file mode 100644 index 00000000..786bbfa7 --- /dev/null +++ b/server/src/services/weeklyDigest.js @@ -0,0 +1,76 @@ +import Post from "../models/Post.js"; +import User from "../models/User.js"; +import { calculatePostScore } from "../util/score.js"; + +export async function generateWeeklyDigest(limit = 50, skip = 0) { + const users = await User.find().skip(skip).limit(limit); + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + const recentPosts = await Post.aggregate([ + { + $match: { + published_at: { $gte: oneWeekAgo }, + tags: { $exists: true, $ne: [] }, + }, + }, + { + $lookup: { + from: "likes", + localField: "_id", + foreignField: "post", + as: "likes", + }, + }, + { + $addFields: { + likeCount: { $size: "$likes" }, + }, + }, + { + $project: { + _id: 1, + title: 1, + content: 1, + tags: 1, + likeCount: 1, + published_at: 1, + author: 1, + }, + }, + ]); + + const digestByUser = []; + + for (const user of users) { + const userPosts = recentPosts.filter( + (post) => post.author?.toString() === user._id.toString(), + ); + const tagFrequency = {}; + + userPosts.forEach((post) => { + if (Array.isArray(post.tags)) { + post.tags.forEach((tag) => { + tagFrequency[tag] = (tagFrequency[tag] || 0) + 1; + }); + } + }); + + const scoredPosts = recentPosts.map((post) => ({ + title: post.title, + content: post.content, + tags: post.tags, + score: calculatePostScore(post.likeCount, post.tags, tagFrequency), + })); + + scoredPosts.sort((a, b) => b.score - a.score); + + digestByUser.push({ + userId: user._id, + email: user.email, + topPosts: scoredPosts.slice(0, 5), + }); + } + + return digestByUser; +} diff --git a/server/src/util/makeDigestHtml.js b/server/src/util/makeDigestHtml.js new file mode 100644 index 00000000..dd73858c --- /dev/null +++ b/server/src/util/makeDigestHtml.js @@ -0,0 +1,24 @@ +export function makeDigestHtml(posts) { + return ` +
This is an automated email, please do not reply.
+