diff --git a/config/env.js b/config/env.js index d764aa0..d1b8abb 100644 --- a/config/env.js +++ b/config/env.js @@ -13,5 +13,7 @@ export const { ARCJET_KEY, QSTASH_URL, QSTASH_TOKEN, + EMAIL_PASSWORD, + EMAIL_USER, } = process.env; diff --git a/config/nodemailer.js b/config/nodemailer.js new file mode 100644 index 0000000..6a5249b --- /dev/null +++ b/config/nodemailer.js @@ -0,0 +1,14 @@ +import nodemailer from 'nodemailer'; +import { EMAIL_USER, EMAIL_PASSWORD } from './env.js'; + +export const accountEmail = EMAIL_USER; + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: accountEmail, + pass: EMAIL_PASSWORD + } +}) + +export default transporter; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index b457bd7..835f6b1 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -25,12 +25,12 @@ export const signUp = async (req, res, next) => { const user = await User.create([{ name, email, password: hashedPassword }], { session }); - const token = jwt.sign({ userId: user[0]._id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN }); + const token = jwt.sign({ userId: user[0]._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); await session.commitTransaction(); session.endSession(); - res.status(201).json({ success: true, data: { user: user[0], token } }); + res.status(201).json({ success: true, message: 'User signed up successfully' ,data: { user: user[0], token } }); } catch (error) { await session.abortTransaction(); session.endSession(); @@ -66,4 +66,3 @@ export const signIn = async (req, res, next) => { } } -export const signOut = async (req, res, next) => {} \ No newline at end of file diff --git a/controllers/subscription.controller.js b/controllers/subscription.controller.js index 1d37dc4..7a9cf04 100644 --- a/controllers/subscription.controller.js +++ b/controllers/subscription.controller.js @@ -1,6 +1,7 @@ import Subscription from '../models/subscription.model.js'; import { SERVER_URL } from '../config/env.js'; import { workflowClient } from '../config/upstash.js'; + export const createSubscription = async (req, res, next) => { try { const subscription = await Subscription.create({ ...req.body, user: req.user._id }); diff --git a/controllers/workflow.controller.js b/controllers/workflow.controller.js index 81b4b47..4ab5e9b 100644 --- a/controllers/workflow.controller.js +++ b/controllers/workflow.controller.js @@ -4,14 +4,14 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const { serve } = require('@upstash/workflow/express'); -const REMAINDERS = [7, 5, 2, 1]; +const REMINDERS = [7, 5, 2, 1]; import Subscription from '../models/subscription.model.js'; +import { sendReminderEmail } from '../utils/send-email.js'; - -export const sendRemainders = serve(async (context) => { +export const sendReminders = serve(async (context) => { const { subscriptionId } = context.requestPayload; - const subscription = await fetchSubscription(subscriptionId); + const subscription = await fetchSubscription(context, subscriptionId); if (!subscription || subscription.status !== 'active' ) return; const renewalDate = dayjs(subscription.renewalDate); @@ -22,12 +22,14 @@ export const sendRemainders = serve(async (context) => { } - for (const daysBefore of REMAINDERS) { + for (const daysBefore of REMINDERS) { const reminderDate = renewalDate.subtract(daysBefore, 'day'); if (reminderDate.isAfter(dayjs())) { - await sleepUntilReminder(context, `Reminder ${daysBefore}-days-before`, reminderDate); + await sleepUntilReminder(context, `Reminder ${daysBefore} -days-before`, reminderDate); + } + if (dayjs().isSame(reminderDate, 'day')) { + await triggerReminder(context, `${daysBefore} days before reminder`, subscription); } - await triggerReminder(context, `Reminder ${daysBefore}-days-before`); } }); @@ -42,8 +44,14 @@ const sleepUntilReminder = async (context, label, date) => { await context.sleepUntil(label, date.toDate()); } -const triggerReminder = async (context, label) => { - return await context.run(label, () => { +const triggerReminder = async (context, label, subscription) => { + return await context.run(label,async () => { console.log(`Triggering ${label} reminder`); + + await sendReminderEmail({ + to: subscription.user.email, + type: label, + subscription, + }) }); } \ No newline at end of file diff --git a/middlewares/arcjet.middleware.js b/middlewares/arcjet.middleware.js index 6735d6a..47b65e2 100644 --- a/middlewares/arcjet.middleware.js +++ b/middlewares/arcjet.middleware.js @@ -2,7 +2,7 @@ import aj from '../config/arcjet.js'; const arcjectMiddleware = async (req, res, next) => { try { - const decision = await aj.protect(req); + const decision = await aj.protect(req, {requested: 1 }); if (decision.isDenied()) { if(decision.reason.isRateLimit()) return res.status(429).json({ success: false, message: 'Rate limit exceeded' }); if(decision.reason.isBot()) return res.status(403).json({ success: false, message: 'Bot detected' }); diff --git a/middlewares/auth.middleware.js b/middlewares/auth.middleware.js index 9474c58..7f5ab77 100644 --- a/middlewares/auth.middleware.js +++ b/middlewares/auth.middleware.js @@ -1,17 +1,18 @@ import jwt from 'jsonwebtoken'; import User from '../models/user.model.js'; +import { JWT_SECRET } from '../config/env.js'; const authorize = async (req, res, next) => { try { let token; - if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { + if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { token = req.headers.authorization.split(' ')[1]; } if (!token) return res.status(401).json({ success: false, message: 'Not authorized to access this route' }); - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, JWT_SECRET); const user = await User.findById(decoded.userId); diff --git a/middlewares/error.middleware.js b/middlewares/error.middleware.js index 9f21aa5..d34e8d4 100644 --- a/middlewares/error.middleware.js +++ b/middlewares/error.middleware.js @@ -2,7 +2,7 @@ const errorMiddleware = (err, req, res, next) => { try { let error = { ...err }; error.message = err.message; - console.log(err); + console.error(err); if (err.name === 'CastError') { const message = `Resource not found with id of ${err.value}`; @@ -17,7 +17,7 @@ const errorMiddleware = (err, req, res, next) => { } if (err.name === 'ValidationError') { - const message = Object.values(err.errors).map((val) => val.message); + const message = Object.values(err.errors).map(val => val.message); error = new Error(message); error.statusCode = 400; } diff --git a/models/subscription.model.js b/models/subscription.model.js index 634081c..c8c9acb 100644 --- a/models/subscription.model.js +++ b/models/subscription.model.js @@ -3,11 +3,11 @@ import mongoose from 'mongoose'; const subscriptionSchema = new mongoose.Schema({ name: { type: String, required: [true, "Name is required"], trim: true, minLenght:2, maxLenght:50 }, price: { type: Number, required: [true, "Price is required"], min: [0, "Price must be at least 0"] }, - currency: { type: String, required: [true, "Currency is required"], trim: true, enum: ["USD", "EUR", "GBP"] }, + currency: { type: String, enum: ["USD", "EUR", "GBP"], default: "USD" }, frequency: { type: String, enum: ["daily", "weekly", "monthly", "yearly"], default: "monthly" }, category: { type: String, enum: ["business", "entertainment", "health", "science", "sports", "technology"], required: [true, "Category is required"] }, paymentMethod: { type: String, required: [true, "Payment method is required"], trim: true }, - status: { type: String, enum: ["active", "trial", "past_due", "canceled"], default: "active" }, + status: { type: String, enum: ["active", "expired", "canceled"], default: "active" }, startDate: { type: Date, required: [true, "Start date is required"], validate: { validator: function (value) { return value <= new Date(); }, message: "Start date must be in the past" } }, renewalDate: { type: Date, validate: { validator: function (value) { return value > this.startDate; }, message: "Renewal date must be in the future" } }, user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: [true, "User is required"], index: true } diff --git a/package-lock.json b/package-lock.json index 757095a..7f5ccf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "jsonwebtoken": "^9.0.2", "mongodb": "^6.13.0", "mongoose": "^8.10.0", - "morgan": "~1.9.1" + "morgan": "~1.9.1", + "nodemailer": "^6.10.0" }, "devDependencies": { "@eslint/js": "^9.20.0", @@ -2064,6 +2065,14 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", diff --git a/package.json b/package.json index c10bc9d..31fd073 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "jsonwebtoken": "^9.0.2", "mongodb": "^6.13.0", "mongoose": "^8.10.0", - "morgan": "~1.9.1" + "morgan": "~1.9.1", + "nodemailer": "^6.10.0" }, "devDependencies": { "@eslint/js": "^9.20.0", diff --git a/routes/auth.routes.js b/routes/auth.routes.js index 3ac5a0e..592ab68 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -1,10 +1,9 @@ import { Router } from 'express'; -import { signIn, signOut, signUp } from '../controllers/auth.controller.js'; +import { signIn, signUp } from '../controllers/auth.controller.js'; const authRouter = Router(); authRouter.post('/sign-up', signUp); authRouter.post('/sign-in', signIn); -authRouter.post('/sign-out', signOut); export default authRouter; diff --git a/routes/workflow.routes.js b/routes/workflow.routes.js index 6c33f9e..6f5ffdf 100644 --- a/routes/workflow.routes.js +++ b/routes/workflow.routes.js @@ -1,8 +1,8 @@ import { Router } from "express"; -import { sendRemainders } from "../controllers/workflow.controller.js"; +import { sendReminders } from "../controllers/workflow.controller.js"; const workflowRouter = Router(); -workflowRouter.post("/", sendRemainders); +workflowRouter.post("/subscription/reminder", sendReminders); export default workflowRouter; \ No newline at end of file diff --git a/utils/email-template.js b/utils/email-template.js new file mode 100644 index 0000000..c21d778 --- /dev/null +++ b/utils/email-template.js @@ -0,0 +1,94 @@ +export const generateEmailTemplate = ({ + userName, + subscriptionName, + renewalDate, + planName, + price, + paymentMethod, + accountSettingsLink, + supportLink, + daysLeft, + }) => ` +
+ + + + + + + + + + +
+

SubDub

+
+

Hello ${userName},

+ +

Your ${subscriptionName} subscription is set to renew on ${renewalDate} (${daysLeft} days from today).

+ + + + + + + + + + + +
+ Plan: ${planName} +
+ Price: ${price} +
+ Payment Method: ${paymentMethod} +
+ +

If you'd like to make changes or cancel your subscription, please visit your account settings before the renewal date.

+ +

Need help? Contact our support team anytime.

+ +

+ Best regards,
+ The SubDub Team +

+
+

+ SubDub Inc. | 123 Main St, Anytown, AN 12345 +

+

+ Unsubscribe | + Privacy Policy | + Terms of Service +

+
+
+ `; + + export const emailTemplates = [ + { + label: "7 days before reminder", + generateSubject: (data) => + `📅 Reminder: Your ${data.subscriptionName} Subscription Renews in 7 Days!`, + generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 7 }), + }, + { + label: "5 days before reminder", + generateSubject: (data) => + `⏳ ${data.subscriptionName} Renews in 5 Days – Stay Subscribed!`, + generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 5 }), + }, + { + label: "2 days before reminder", + generateSubject: (data) => + `🚀 2 Days Left! ${data.subscriptionName} Subscription Renewal`, + generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 2 }), + }, + { + label: "1 days before reminder", + generateSubject: (data) => + `⚡ Final Reminder: ${data.subscriptionName} Renews Tomorrow!`, + generateBody: (data) => generateEmailTemplate({ ...data, daysLeft: 1 }), + }, + ]; \ No newline at end of file diff --git a/utils/send-email.js b/utils/send-email.js new file mode 100644 index 0000000..1d0b04d --- /dev/null +++ b/utils/send-email.js @@ -0,0 +1,36 @@ +import { emailTemplates } from './email-template.js' +import dayjs from 'dayjs' +import transporter, { accountEmail } from '../config/nodemailer.js' + +export const sendReminderEmail = async ({ to, type, subscription }) => { + if(!to || !type) throw new Error('Missing required parameters'); + + const template = emailTemplates.find((t) => t.label === type); + + if(!template) throw new Error('Invalid email type'); + + const mailInfo = { + userName: subscription.user.name, + subscriptionName: subscription.name, + renewalDate: dayjs(subscription.renewalDate).format('MMM D, YYYY'), + planName: subscription.name, + price: `${subscription.currency} ${subscription.price} (${subscription.frequency})`, + paymentMethod: subscription.paymentMethod, + } + + const message = template.generateBody(mailInfo); + const subject = template.generateSubject(mailInfo); + + const mailOptions = { + from: accountEmail, + to: to, + subject: subject, + html: message, + } + + transporter.sendMail(mailOptions, (error, info) => { + if(error) return console.log(error, 'Error sending email'); + + console.log('Email sent: ' + info.response); + }) +} \ No newline at end of file