From ea61617b63fc23ad5f47a76c5aa853a1bae40573 Mon Sep 17 00:00:00 2001 From: nandul2424 Date: Sun, 9 Nov 2025 17:22:40 +0530 Subject: [PATCH 1/2] deleting the demo test file --- sum.test.js | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 sum.test.js diff --git a/sum.test.js b/sum.test.js deleted file mode 100644 index 88ca872..0000000 --- a/sum.test.js +++ /dev/null @@ -1,16 +0,0 @@ -import {sum} from './sum.js' -import {expect, test} from "@jest/globals"; - -test("correctly adds 3 numbers",()=>{ - - expect(sum(1,2,3)).toBe(6); -}); - -test("main app.js has correctly working routes",()=>{ - - -}) - - - - From e4c455d5cecce339cb9de98083ba9bd6371019e0 Mon Sep 17 00:00:00 2001 From: nandul2424 Date: Mon, 10 Nov 2025 11:15:31 +0530 Subject: [PATCH 2/2] adding email reminder feature with upstash and nodemailer --- Config/env.js | 2 +- Config/nodemailer.js | 36 +++ Config/upstash.js | 8 + Controllers/auth.controller.js | 5 +- Controllers/subscription.controller.js | 19 +- Controllers/workflow.controller.js | 57 +++++ Models/subscription.model.js | 5 +- app.js | 2 + package-lock.json | 331 ++++++++++++++++++++++++- package.json | 6 +- routes/workflow.routes.js | 8 + utills/email.template.js | 94 +++++++ utills/send.email.js | 0 13 files changed, 564 insertions(+), 9 deletions(-) create mode 100644 Config/nodemailer.js create mode 100644 Config/upstash.js create mode 100644 Controllers/workflow.controller.js create mode 100644 routes/workflow.routes.js create mode 100644 utills/email.template.js create mode 100644 utills/send.email.js diff --git a/Config/env.js b/Config/env.js index 428d9e3..5e32369 100644 --- a/Config/env.js +++ b/Config/env.js @@ -4,4 +4,4 @@ config({ path: '.env' }); -export const {DB_URI,TEST_DB_URI,PORT,JWT_EXPIRES_IN,JWT_SECRET,ARCJET_KEY,NODE_ENV} = process.env; +export const {DB_URI,TEST_DB_URI,PORT,JWT_EXPIRES_IN,JWT_SECRET,ARCJET_KEY,NODE_ENV,QSTASH_TOKEN,QSTASH_URL,QSTASH_CURRENT_SIGNING_KEY,QSTASH_NEXT_SIGNING_KEY,SERVER_URL,EMAIL_USER,EMAIL_PASSWORD} = process.env; diff --git a/Config/nodemailer.js b/Config/nodemailer.js new file mode 100644 index 0000000..74e0153 --- /dev/null +++ b/Config/nodemailer.js @@ -0,0 +1,36 @@ +import nodemailer from 'nodemailer'; +import {EMAIL_PASSWORD, EMAIL_USER} from "./env.js"; + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user:EMAIL_USER, + pass:EMAIL_PASSWORD + } +}); + +export const sendAccountCreatedEmail = async (toEmail, username) => { + await transporter.sendMail({ + from: `"My App" <${EMAIL_USER}>`, + to: toEmail, + subject: "Account Created Successfully 🎉", + html: ` +

Welcome, ${username}!

+

Your account has been created successfully.

+

We're glad to have you with us 💙

+ `, + }); +}; + +export const sendNewsletterResponseEmail = async (toEmail, name) => { + await transporter.sendMail({ + from: `"My App" <${EMAIL_USER}>`, + to: toEmail, + subject: "Newsletter Message Received ✅", + html: ` +

Hey ${name},

+

Thanks for reaching out! Your message has been successfully sent.

+

We'll get back to you soon.

+ `, + }); +}; diff --git a/Config/upstash.js b/Config/upstash.js new file mode 100644 index 0000000..2a7df62 --- /dev/null +++ b/Config/upstash.js @@ -0,0 +1,8 @@ +import {Client as WorkflowClient} from '@upstash/workflow' + +import {QSTASH_TOKEN,QSTASH_URL} from "./env.js"; + +export const workflowClient=new WorkflowClient({ + baseURL:QSTASH_URL, + token:QSTASH_TOKEN, +}) \ No newline at end of file diff --git a/Controllers/auth.controller.js b/Controllers/auth.controller.js index a91ba4b..1625473 100644 --- a/Controllers/auth.controller.js +++ b/Controllers/auth.controller.js @@ -3,6 +3,7 @@ import User from "../Models/user.model.js"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { JWT_EXPIRES_IN, JWT_SECRET } from "../Config/env.js"; +import {sendAccountCreatedEmail} from "../Config/nodemailer.js"; export const signUp = async (req, res, next) => { @@ -44,12 +45,14 @@ export const signUp = async (req, res, next) => { { expiresIn: JWT_EXPIRES_IN } ); + await sendAccountCreatedEmail(newUser.email, newUser.name); + await session.commitTransaction(); session.endSession(); res.status(201).json({ success: true, - message: "User created successfully", + message: "User created successfully.. email sent!!", data: { token, }, diff --git a/Controllers/subscription.controller.js b/Controllers/subscription.controller.js index 417b1c2..9ed34ee 100644 --- a/Controllers/subscription.controller.js +++ b/Controllers/subscription.controller.js @@ -1,13 +1,26 @@ -import subSchema from "../models/subscription.model.js"; + import Subscription from "../models/subscription.model.js"; +import {workflowClient} from "../Config/upstash.js"; +import {SERVER_URL} from "../Config/env.js"; export const createSubscriptions=async(req,res,next)=>{ try{ - const newSubscription=await subSchema.create({ + const newSubscription=await Subscription.create({ ...req.body, user:req.user._id - }) + }); + + const {workflowRunId}=await workflowClient.trigger({ + url:`${SERVER_URL}/api/v1/subscriptions/reminder`, + body:{ + subscriptionId:newSubscription.id + }, + headers:{ + "Content-Type":"application/json" + } + + }); res.status(201).json({ status:"success", diff --git a/Controllers/workflow.controller.js b/Controllers/workflow.controller.js new file mode 100644 index 0000000..35a74e0 --- /dev/null +++ b/Controllers/workflow.controller.js @@ -0,0 +1,57 @@ +import {createRequire} from 'module' +import dayjs from 'dayjs' +import workflowRouter from "../routes/workflow.routes.js"; +import Subscription from "../Models/subscription.model.js"; +const require=createRequire(import.meta.url); +const {serve} =require('@upstash/workflow/express'); +//to use commonjs syntax to require serve module + +const REMINDERS=[7,5,2,1]; + +export const sendReminders = serve(async(context)=>{ + + const {subscriptionId} = context.requestPayload; + const subscription=await fetchSubscription(context,subscriptionId); + + if(!subscription || subscription.status!='active'){ + return; + } + + const renewalDate=dayjs(subscription.renewalDate); + + if(renewalDate.isBefore(dayjs())){ + console.log(`Renewal date is passed for subscription ${subscriptionId}.. stopping workflow`); + return; + } + + for(const daysBefore of REMINDERS){ + const reminderDate=renewalDate.subtract(daysBefore,'day'); + + if(reminderDate.isAfter(dayjs())){ + await sleepUntilReminder(context,`reminder ${daysBefore} days before`,reminderDate); + } + + await triggerReminder(context,`Reminder ${daysBefore} days before`); + } +} + +) + +const fetchSubscription=async(context,subscriptionId)=>{ + + return await context.run('get subscriptions',()=>{ + return Subscription.findById(subscriptionId).populate('user','name email'); + }); +} + +const sleepUntilReminder=async (context,label,date)=>{ + console.log(`Sleep until ${label} remainder at ${date}`); + await context.sleepUntil(label,date.toDate()); +} + +const triggerReminder=async (context,label)=>{ + return await context.run(label,()=>{ + console.log(`Triggering ${label} reminder`); + + }) +} \ No newline at end of file diff --git a/Models/subscription.model.js b/Models/subscription.model.js index 9c54d4a..d36bbbb 100644 --- a/Models/subscription.model.js +++ b/Models/subscription.model.js @@ -76,7 +76,8 @@ const subSchema = new mongoose.Schema({ timeStamps: true }) -const Subscription=new mongoose.model('Subscription',subSchema); +const Subscription = + mongoose.models.Subscription || mongoose.model("Subscription", subSchema); //create and stores subscriptions in subscriptions collection -export default Subscription; \ No newline at end of file +export default Subscription; diff --git a/app.js b/app.js index 459d8eb..ad4de6f 100644 --- a/app.js +++ b/app.js @@ -12,12 +12,14 @@ import authRoutes from "./routes/auth.routes.js"; import subscriptionRoutes from "./routes/subscription.routes.js"; import userRoutes from "./routes/user.routes.js"; import authMiddleware from "./Middlewares/auth.middleware.js"; +import workflowRouter from "./routes/workflow.routes.js"; app.use(express.json()); app.use("/api/v1/auth", authRoutes); app.use("/api/v1/subscriptions", subscriptionRoutes); app.use("/api/v1/users", userRoutes); +app.use("/api/v1/workflows", workflowRouter); app.use(errorHandler); diff --git a/package-lock.json b/package-lock.json index 76e599b..f3e3f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,15 +12,19 @@ "@arcjet/node": "^1.0.0-beta.13", "@prisma/client": "^6.18.0", "@prisma/extension-accelerate": "^2.0.2", + "@upstash/qstash": "^2.8.4", + "@upstash/workflow": "^0.2.22", "bcryptjs": "^3.0.2", "cookie-parser": "~1.4.4", + "dayjs": "^1.11.19", "debug": "~2.6.9", "dotenv": "^17.2.3", "express": "~4.16.1", "jsonwebtoken": "^9.0.2", "mongodb": "^6.20.0", "mongoose": "^8.19.2", - "morgan": "~1.9.1" + "morgan": "~1.9.1", + "nodemailer": "^7.0.10" }, "devDependencies": { "@babel/preset-env": "^7.28.5", @@ -37,6 +41,92 @@ "supertest": "^7.1.4" } }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz", + "integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", + "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/ui-utils": "1.2.11", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/@arcjet/analyze": { "version": "1.0.0-beta.13", "resolved": "https://registry.npmjs.org/@arcjet/analyze/-/analyze-1.0.0-beta.13.tgz", @@ -2987,6 +3077,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3214,6 +3313,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3611,6 +3716,29 @@ "win32" ] }, + "node_modules/@upstash/qstash": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-2.8.4.tgz", + "integrity": "sha512-iojHWUlRoC3M2e4XQ1NFEgC+7Orurrm5uIrPn5WilU7LvWQoocyjYBXR0VUalXkeMAmFyk4blF0EOYZY4igdIQ==", + "license": "MIT", + "dependencies": { + "crypto-js": ">=4.2.0", + "jose": "^5.2.3", + "neverthrow": "^7.0.1" + } + }, + "node_modules/@upstash/workflow": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@upstash/workflow/-/workflow-0.2.22.tgz", + "integrity": "sha512-TaLIZ2iNTMQ796FdHj3KGC+8Zq3rVRjdHke+S+0Dj4FRj70ByfWqN3o4L7uY5r/CNhRpRjqbqgAmWguvI6z8xw==", + "license": "MIT", + "dependencies": { + "@ai-sdk/openai": "^1.2.1", + "@upstash/qstash": "^2.8.2", + "ai": "^4.1.54", + "zod": "^3.24.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3657,6 +3785,32 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "4.3.19", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", + "integrity": "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/react": "1.2.12", + "@ai-sdk/ui-utils": "1.2.11", + "@opentelemetry/api": "1.9.0", + "jsondiffpatch": "0.6.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4661,6 +4815,18 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4738,6 +4904,15 @@ "node": ">= 0.6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -4772,6 +4947,12 @@ "wrappy": "1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -6917,6 +7098,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6964,6 +7154,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6991,6 +7187,35 @@ "node": ">=6" } }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "license": "MIT", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/jsondiffpatch/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -7549,6 +7774,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -7581,6 +7824,15 @@ "node": ">= 0.6" } }, + "node_modules/neverthrow": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-7.2.0.tgz", + "integrity": "sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/new-find-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", @@ -7640,6 +7892,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz", + "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -8265,6 +8526,16 @@ "destr": "^2.0.3" } }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8419,6 +8690,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -8987,6 +9264,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -9062,6 +9352,18 @@ "b4a": "^1.6.4" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -9331,6 +9633,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -9668,6 +9979,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index a292094..31ab3d2 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,19 @@ "@arcjet/node": "^1.0.0-beta.13", "@prisma/client": "^6.18.0", "@prisma/extension-accelerate": "^2.0.2", + "@upstash/qstash": "^2.8.4", + "@upstash/workflow": "^0.2.22", "bcryptjs": "^3.0.2", "cookie-parser": "~1.4.4", + "dayjs": "^1.11.19", "debug": "~2.6.9", "dotenv": "^17.2.3", "express": "~4.16.1", "jsonwebtoken": "^9.0.2", "mongodb": "^6.20.0", "mongoose": "^8.19.2", - "morgan": "~1.9.1" + "morgan": "~1.9.1", + "nodemailer": "^7.0.10" }, "devDependencies": { "@babel/preset-env": "^7.28.5", diff --git a/routes/workflow.routes.js b/routes/workflow.routes.js new file mode 100644 index 0000000..974f395 --- /dev/null +++ b/routes/workflow.routes.js @@ -0,0 +1,8 @@ +import express, {Router} from 'express'; +import {sendReminders} from "../Controllers/workflow.controller.js"; + +const workflowRouter = Router(); + +workflowRouter.post('/subscription/reminder', sendReminders); + +export default workflowRouter; \ No newline at end of file diff --git a/utills/email.template.js b/utills/email.template.js new file mode 100644 index 0000000..4224ca1 --- /dev/null +++ b/utills/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/utills/send.email.js b/utills/send.email.js new file mode 100644 index 0000000..e69de29