From 18dc621c8a9ac84a4c9071d6d95f5a9ef29c79bb Mon Sep 17 00:00:00 2001 From: laryhills Date: Wed, 30 Jul 2025 23:54:11 +0100 Subject: [PATCH 1/4] feat: implement user reminder functionality with cron job settings and email notifications --- backend/.env.example | 9 + backend/logs/error.log | 5 - backend/package-lock.json | 17 ++ backend/package.json | 7 +- backend/prisma/migrations/migration_lock.toml | 2 +- backend/prisma/schema.prisma | 3 + backend/src/controllers/auth.controller.ts | 3 + backend/src/core/config/settings.ts | 18 ++ backend/src/db.ts | 2 +- backend/src/jobs/userReminder.job.ts | 158 ++++++++++++++++++ backend/src/middlewares/authentication.ts | 3 + backend/src/server.ts | 19 +++ backend/src/services/jobScheduler.service.ts | 123 ++++++++++++++ backend/src/services/notification.service.ts | 127 ++++++++++++++ backend/src/services/user.service.ts | 78 +++++++++ backend/src/utils/service/emailNotifier.ts | 126 ++++++++++++++ 16 files changed, 691 insertions(+), 9 deletions(-) create mode 100644 backend/src/jobs/userReminder.job.ts create mode 100644 backend/src/services/jobScheduler.service.ts create mode 100644 backend/src/services/notification.service.ts diff --git a/backend/.env.example b/backend/.env.example index 9d5754b..079dd2c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,3 +30,12 @@ EMAIL_FROM_ADDRESS=noreply@example.com APP_PORT=3000 SERVER_ENVIRONMENT=DEVELOPMENT AURORA_WEB_APP_BASE_URL=http://localhost:3000 + + +# ======================== +# User Reminder CronJob Settings +# ======================== +REMINDER_ENABLED=true +CRON_SCHEDULE="0 2 * * *" +REMINDER_MAX_RETRIES=2 +REMINDER_RETRY_INTERVAL_HOURS=24 \ No newline at end of file diff --git a/backend/logs/error.log b/backend/logs/error.log index 828f7c0..e69de29 100644 --- a/backend/logs/error.log +++ b/backend/logs/error.log @@ -1,5 +0,0 @@ -{"level":"error","message":"TypeError: Cannot read properties of undefined (reading 'id')\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:39\n at Generator.next ()\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:71\n at new Promise ()\n at __awaiter (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:4:12)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:7:82\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\middlewares\\async.ts:6:19\n at Layer.handle [as handle_request] (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\express\\lib\\router\\layer.js:95:5)\n at next (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\express\\lib\\router\\route.js:149:13)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\middlewares\\validator.ts:25:12"} -{"level":"error","message":"TypeError: Cannot read properties of undefined (reading 'id')\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:39\n at Generator.next ()\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:71\n at new Promise ()\n at __awaiter (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:4:12)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:7:82\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\middlewares\\async.ts:6:19\n at Layer.handle [as handle_request] (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\express\\lib\\router\\layer.js:95:5)\n at next (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\express\\lib\\router\\route.js:149:13)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\middlewares\\validator.ts:25:12"} -{"level":"error","message":"SyntaxError: Unexpected token '`', ...\"entence\": `\"My daugh\"... is not valid JSON\n at JSON.parse ()\n at parse (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\types\\json.js:92:19)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:214:14)\n at invokeCallback (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:518:28)\n at IncomingMessage.emit (node:domain:489:12)\n at endReadableNT (node:internal/streams/readable:1698:12)"} -{"level":"error","message":"SyntaxError: Unexpected token '`', ...\"entence\": `\"My daugh\"... is not valid JSON\n at JSON.parse ()\n at parse (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\types\\json.js:92:19)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:214:14)\n at invokeCallback (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:518:28)\n at IncomingMessage.emit (node:domain:489:12)\n at endReadableNT (node:internal/streams/readable:1698:12)"} -{"level":"error","message":"SyntaxError: Unexpected token '`', ...\"entence\": `\"My daugh\"... is not valid JSON\n at JSON.parse ()\n at parse (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\types\\json.js:92:19)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:214:14)\n at invokeCallback (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:518:28)\n at IncomingMessage.emit (node:domain:489:12)\n at endReadableNT (node:internal/streams/readable:1698:12)"} diff --git a/backend/package-lock.json b/backend/package-lock.json index a9c5d76..9ec1a58 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@stellar/stellar-sdk": "13.3.0", "@types/compression": "^1.7.5", + "@types/node-cron": "^3.0.11", "axios": "^1.7.7", "bcrypt": "^6.0.0", "compression": "^1.8.0", @@ -27,6 +28,7 @@ "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "node-cron": "^4.2.1", "nodemailer": "^6.10.0", "pg": "^8.14.1", "rimraf": "^6.0.1", @@ -2010,6 +2012,12 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "6.4.17", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", @@ -5993,6 +6001,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-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", diff --git a/backend/package.json b/backend/package.json index 43c0533..3a23457 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "start:prod": "node build/src/server.js", "build": "rimraf ./build && tsc", "start": "npm run build && node build/src/server.js", - "import:questions": "ts-node --project tsconfig.json scripts/importQuestions.ts" + "import:questions": "ts-node --project tsconfig.json scripts/importQuestions.ts", + "seed": "ts-node --project tsconfig.json prisma/seed.ts" }, "keywords": [], "author": "", @@ -19,6 +20,7 @@ "dependencies": { "@stellar/stellar-sdk": "13.3.0", "@types/compression": "^1.7.5", + "@types/node-cron": "^3.0.11", "axios": "^1.7.7", "bcrypt": "^6.0.0", "compression": "^1.8.0", @@ -35,6 +37,7 @@ "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "node-cron": "^4.2.1", "nodemailer": "^6.10.0", "pg": "^8.14.1", "rimraf": "^6.0.1", @@ -70,4 +73,4 @@ "ts-node": "^10.9.2", "typescript": "^5.8.3" } -} +} \ No newline at end of file diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "postgresql" \ No newline at end of file +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5510ca9..12d8213 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -36,6 +36,9 @@ model User { phoneNumber String? profileImage String? status Status @default(INACTIVE) + lastLoginAt DateTime? + lastActivityAt DateTime? + lastReminderSent DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt wallet Wallet? diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index afcf54d..d1ca2e9 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -90,6 +90,9 @@ export const login = asyncHandler(async (req: Request, res: Response) => { const isPasswordValid = await Bcrypt.compare(password, user.password) if (!isPasswordValid) throw new BadRequestError("Invalid credentials") + // Update login and activity timestamps + await UserService.updateLastLogin(user.id) + const token = Jwt.issue({ id: user.id }, "1d") const userResponse = { diff --git a/backend/src/core/config/settings.ts b/backend/src/core/config/settings.ts index a724dd0..5279ba0 100644 --- a/backend/src/core/config/settings.ts +++ b/backend/src/core/config/settings.ts @@ -22,6 +22,18 @@ const envVarsSchema = Joi.object() AURORA_WEB_APP_BASE_URL: Joi.string() .required() .description("Base URL for Aurora Web App"), + REMINDER_ENABLED: Joi.boolean() + .default(true) + .description("Enable reminder emails"), + CRON_SCHEDULE: Joi.string() + .default("0 2 * * *") + .description("CRON schedule for reminder job"), + REMINDER_MAX_RETRIES: Joi.number() + .default(2) + .description("Maximum retries for failed email reminders"), + REMINDER_RETRY_INTERVAL_HOURS: Joi.number() + .default(24) + .description("Hours between email retry attempts"), }) .unknown(); @@ -44,6 +56,12 @@ const serverSettings = { password: envVars.EMAIL_PASSWORD, fromAddress: envVars.EMAIL_FROM_ADDRESS, }, + reminders: { + enabled: envVars.REMINDER_ENABLED, + cronSchedule: envVars.CRON_SCHEDULE, + maxRetries: envVars.REMINDER_MAX_RETRIES, + retryIntervalHours: envVars.REMINDER_RETRY_INTERVAL_HOURS, + }, }; export default serverSettings; diff --git a/backend/src/db.ts b/backend/src/db.ts index e8d5f36..872202f 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -20,7 +20,7 @@ const connectDB = async () => { console.log("πŸ“¦ Connected to the Supabase database successfully!"); client.release(); } catch (error) { - console.error("❌ Database connection error:", error); + console.error("❌ Database connection errors:", error); process.exit(1); // Exit the process if the connection fails } }; diff --git a/backend/src/jobs/userReminder.job.ts b/backend/src/jobs/userReminder.job.ts new file mode 100644 index 0000000..f4e9cac --- /dev/null +++ b/backend/src/jobs/userReminder.job.ts @@ -0,0 +1,158 @@ +import NotificationService from "../services/notification.service"; +import EmailNotifier from "../utils/service/emailNotifier"; +import UserService from "../services/user.service"; + +interface JobResult { + success: boolean; + stats: { + candidatesFound: number; + emailsSent: number; + emailsFailed: number; + }; + errors: Array<{ email: string; error: string }>; + executionTime: number; +} + +class UserReminderJob { + /** + * Main job function to check inactive users and send reminders + */ + public static async checkInactiveUsers(maxRetries: number = 2): Promise { + const startTime = Date.now(); + console.log("πŸ”„ Starting user reminder job..."); + + try { + // Get reminder statistics for logging + const stats = await NotificationService.getReminderStats(); + console.log("πŸ“Š Reminder Stats:", { + totalInactive: stats.totalInactive, + candidatesFound: stats.candidatesFound, + breakdown: stats.breakdown, + }); + + // Find users who need reminders + const candidates = await NotificationService.findReminderCandidates(); + + if (candidates.length === 0) { + console.log("βœ… No users found requiring reminders"); + return { + success: true, + stats: { + candidatesFound: 0, + emailsSent: 0, + emailsFailed: 0, + }, + errors: [], + executionTime: Date.now() - startTime, + }; + } + + console.log(`πŸ“§ Sending reminders to ${candidates.length} users`); + + // Prepare email data + const reminderEmails = candidates.map(candidate => ({ + email: candidate.email, + firstName: candidate.firstName || undefined, + reminderType: candidate.reminderType, + daysSinceActivity: candidate.daysSinceActivity, + })); + + // Send emails with retry logic + const emailResults = await EmailNotifier.sendReminderEmails(reminderEmails, maxRetries); + + // Update lastReminderSent for successfully sent emails + const successfulEmails = reminderEmails.slice(0, emailResults.success); + await this.updateReminderSentTimestamps(successfulEmails.map(email => + candidates.find(c => c.email === email.email)!.id + )); + + const executionTime = Date.now() - startTime; + + console.log("βœ… User reminder job completed:", { + candidatesFound: candidates.length, + emailsSent: emailResults.success, + emailsFailed: emailResults.failed, + executionTimeMs: executionTime, + }); + + return { + success: true, + stats: { + candidatesFound: candidates.length, + emailsSent: emailResults.success, + emailsFailed: emailResults.failed, + }, + errors: emailResults.errors, + executionTime, + }; + + } catch (error) { + const executionTime = Date.now() - startTime; + console.error("❌ User reminder job failed:", error); + + return { + success: false, + stats: { + candidatesFound: 0, + emailsSent: 0, + emailsFailed: 0, + }, + errors: [{ email: "system", error: error instanceof Error ? error.message : "Unknown error" }], + executionTime, + }; + } + } + + /** + * Update lastReminderSent timestamp for users who received reminders + */ + private static async updateReminderSentTimestamps(userIds: string[]): Promise { + try { + const updatePromises = userIds.map(userId => + UserService.updateReminderSent(userId) + ); + + await Promise.all(updatePromises); + console.log(`βœ… Updated reminder timestamps for ${userIds.length} users`); + } catch (error) { + console.error("❌ Failed to update reminder timestamps:", error); + // Don't throw here as emails were already sent successfully + } + } + + /** + * Manual trigger for testing purposes + */ + public static async triggerManual(): Promise { + console.log("πŸ”§ Manual trigger of user reminder job"); + return this.checkInactiveUsers(); + } + + /** + * Get job health status + */ + public static async getHealthStatus(): Promise<{ + isHealthy: boolean; + lastRun?: Date; + nextRun?: string; + }> { + try { + // In a real implementation, you might store job execution history + // For now, we'll just check if the services are accessible + await NotificationService.getReminderStats(); + + return { + isHealthy: true, + // These would come from job execution history in a real implementation + lastRun: new Date(), + nextRun: "Based on CRON schedule", + }; + } catch (error) { + return { + isHealthy: false, + }; + } + } +} + +export default UserReminderJob; \ No newline at end of file diff --git a/backend/src/middlewares/authentication.ts b/backend/src/middlewares/authentication.ts index c5379b5..30e28b4 100644 --- a/backend/src/middlewares/authentication.ts +++ b/backend/src/middlewares/authentication.ts @@ -25,6 +25,9 @@ export const isAuthorized = () => { const user = await UserService.readUserById(decoded.payload.id) if (!user) return next(new UnauthorizedError("Unauthorized - User not found")) + // Track user activity + await UserService.updateLastActivity(user.id); + req.user = user res.locals.account = user next() diff --git a/backend/src/server.ts b/backend/src/server.ts index 3653caf..c0c4649 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,14 +3,33 @@ import path from "path"; import app from "./app"; import settings from "./core/config/settings"; import { connectDB } from "./db"; +import JobSchedulerService from "./services/jobScheduler.service"; dotenv.config(); const server = app; const port = settings.serverPort || 8000; +// Connect to database connectDB(); +// Initialize scheduled jobs +JobSchedulerService.initializeJobs(); + + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully'); + JobSchedulerService.shutdown(); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully'); + JobSchedulerService.shutdown(); + process.exit(0); +}); + server.listen(port, () => { console.log(`πŸš€πŸš€πŸš€ Aurora's server is running at http://localhost:${port} πŸš€πŸš€πŸš€`); }); diff --git a/backend/src/services/jobScheduler.service.ts b/backend/src/services/jobScheduler.service.ts new file mode 100644 index 0000000..08a4a74 --- /dev/null +++ b/backend/src/services/jobScheduler.service.ts @@ -0,0 +1,123 @@ +import * as cron from "node-cron"; +import settings from "../core/config/settings"; +import UserReminderJob from "../jobs/userReminder.job"; + +class JobSchedulerService { + private static jobs: Map = new Map(); + + /** + * Initialize all scheduled jobs + */ + public static initializeJobs(): void { + console.log("πŸ”§ Initializing scheduled jobs..."); + + if (!settings.reminders.enabled) { + console.log("⏸️ Reminder jobs are disabled in configuration"); + return; + } + + this.scheduleUserReminderJob(); + console.log(`βœ… ${this.jobs.size} scheduled job(s) initialized`); + } + + /** + * Schedule the user reminder job + */ + private static scheduleUserReminderJob(): void { + const cronSchedule = settings.reminders.cronSchedule; + + if (!cron.validate(cronSchedule)) { + console.error(`❌ Invalid CRON schedule: ${cronSchedule}`); + return; + } + + const job = cron.schedule(cronSchedule, async () => { + try { + console.log("⏰ Running scheduled user reminder job..."); + const result = await UserReminderJob.checkInactiveUsers(settings.reminders.maxRetries); + + if (result.success) { + console.log("βœ… Scheduled reminder job completed successfully:", result.stats); + } else { + console.error("❌ Scheduled reminder job failed:", result.errors); + } + } catch (error) { + console.error("❌ Scheduled reminder job error:", error); + } + }, { + timezone: "UTC", + }); + + this.jobs.set("userReminder", job); + console.log(`πŸ“… User reminder job scheduled with CRON: ${cronSchedule} (UTC)`); + } + + /** + * Start all jobs + */ + public static startAllJobs(): void { + this.jobs.forEach((job, name) => { + job.start(); + console.log(`▢️ Started job: ${name}`); + }); + } + + /** + * Stop all jobs + */ + public static stopAllJobs(): void { + this.jobs.forEach((job, name) => { + job.stop(); + console.log(`⏸️ Stopped job: ${name}`); + }); + } + + /** + * Get job status + */ + public static getJobStatus(): Array<{ name: string; isRunning: boolean; schedule: string }> { + const status: Array<{ name: string; isRunning: boolean; schedule: string }> = []; + + this.jobs.forEach((job, name) => { + status.push({ + name, + isRunning: job.getStatus() === "scheduled", + schedule: name === "userReminder" ? settings.reminders.cronSchedule : "unknown", + }); + }); + + return status; + } + + /** + * Manually trigger user reminder job (for testing) + */ + public static async triggerUserReminderJob(): Promise { + console.log("πŸ”§ Manually triggering user reminder job..."); + return UserReminderJob.triggerManual(); + } + + /** + * Get next run times for all jobs + */ + public static getNextRunTimes(): Array<{ name: string; nextRun: string }> { + // node-cron doesn't provide built-in next run time calculation + // For now, we'll return the schedule pattern + return Array.from(this.jobs.keys()).map(name => ({ + name, + nextRun: `Next run based on schedule: ${name === "userReminder" ? settings.reminders.cronSchedule : "unknown"}`, + })); + } + + /** + * Graceful shutdown of all jobs + */ + public static shutdown(): void { + console.log("πŸ›‘ Shutting down job scheduler..."); + this.stopAllJobs(); + this.jobs.clear(); + console.log("βœ… Job scheduler shutdown complete"); + } +} + +export default JobSchedulerService; \ No newline at end of file diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..8358a3d --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,127 @@ +import UserService from "./user.service"; + +interface InactiveUser { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + lastActivityAt: Date | null; + lastReminderSent: Date | null; + createdAt: Date; +} + +interface ReminderCandidate extends InactiveUser { + daysSinceActivity: number; + daysSinceLastReminder: number; + reminderType: '7-day' | '14-day' | '30-day' | '60-day'; +} + +class NotificationService { + /** + * Calculate days between two dates + */ + private static daysBetween(date1: Date, date2: Date): number { + const diffTime = Math.abs(date2.getTime() - date1.getTime()); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } + + /** + * Get the last activity date (lastActivityAt or createdAt if never active) + */ + private static getLastActivityDate(user: InactiveUser): Date { + return user.lastActivityAt || user.createdAt; + } + + /** + * Determine which reminder type should be sent based on inactivity period + */ + private static determineReminderType(daysSinceActivity: number): '7-day' | '14-day' | '30-day' | '60-day' | null { + if (daysSinceActivity >= 60) return '60-day'; + if (daysSinceActivity >= 30) return '30-day'; + if (daysSinceActivity >= 14) return '14-day'; + if (daysSinceActivity >= 7) return '7-day'; + return null; + } + + /** + * Check if enough time has passed since last reminder to send a new one + */ + private static shouldSendReminder(daysSinceLastReminder: number, reminderType: string): boolean { + // Minimum days between reminders based on type + const minimumIntervals = { + '7-day': 7, + '14-day': 7, + '30-day': 14, + '60-day': 30, + }; + + const minimumInterval = minimumIntervals[reminderType as keyof typeof minimumIntervals] || 7; + return daysSinceLastReminder >= minimumInterval; + } + + /** + * Find users who are candidates for receiving reminders + */ + public static async findReminderCandidates(): Promise { + try { + // Get users inactive for at least 7 days + const inactiveUsers = await UserService.findInactiveUsers(7); + const candidates: ReminderCandidate[] = []; + const now = new Date(); + + for (const user of inactiveUsers) { + const lastActivityDate = this.getLastActivityDate(user); + const daysSinceActivity = this.daysBetween(lastActivityDate, now); + const daysSinceLastReminder = user.lastReminderSent + ? this.daysBetween(user.lastReminderSent, now) + : Infinity; + + const reminderType = this.determineReminderType(daysSinceActivity); + + if (reminderType && this.shouldSendReminder(daysSinceLastReminder, reminderType)) { + candidates.push({ + ...user, + daysSinceActivity, + daysSinceLastReminder, + reminderType, + }); + } + } + + return candidates; + } catch (error) { + console.error("Failed to find reminder candidates:", error); + throw error; + } + } + + /** + * Get reminder statistics for logging + */ + public static async getReminderStats(): Promise<{ + totalInactive: number; + candidatesFound: number; + breakdown: Record; + }> { + try { + const inactiveUsers = await UserService.findInactiveUsers(7); + const candidates = await this.findReminderCandidates(); + + const breakdown = candidates.reduce((acc, candidate) => { + acc[candidate.reminderType] = (acc[candidate.reminderType] || 0) + 1; + return acc; + }, {} as Record); + + return { + totalInactive: inactiveUsers.length, + candidatesFound: candidates.length, + breakdown, + }; + } catch (error) { + console.error("Failed to get reminder stats:", error); + throw error; + } + } +} + +export default NotificationService; \ No newline at end of file diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index cf4f31a..5c84ea2 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -57,6 +57,84 @@ class UserService { where: { id }, }); } + + public static async updateLastLogin(userId: string) { + try { + return await prisma.user.update({ + where: { id: userId }, + data: { + lastLoginAt: new Date(), + lastActivityAt: new Date() + }, + }); + } catch (error) { + console.error("Failed to update last login:", error); + throw new InternalError("Failed to update last login"); + } + } + + public static async updateLastActivity(userId: string) { + try { + return await prisma.user.update({ + where: { id: userId }, + data: { lastActivityAt: new Date() }, + }); + } catch (error) { + console.error("Failed to update last activity:", error); + // Don't throw error for activity tracking to avoid breaking requests + } + } + + public static async findInactiveUsers(days: number) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + try { + return await prisma.user.findMany({ + where: { + status: Status.ACTIVE, + isEmailVerified: true, + OR: [ + { + lastActivityAt: { + lte: cutoffDate, + }, + }, + { + lastActivityAt: null, + createdAt: { + lte: cutoffDate, + }, + }, + ], + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + lastActivityAt: true, + lastReminderSent: true, + createdAt: true, + }, + }); + } catch (error) { + console.error("Failed to find inactive users:", error); + throw new InternalError("Failed to find inactive users"); + } + } + + public static async updateReminderSent(userId: string) { + try { + return await prisma.user.update({ + where: { id: userId }, + data: { lastReminderSent: new Date() }, + }); + } catch (error) { + console.error("Failed to update reminder sent:", error); + throw new InternalError("Failed to update reminder sent"); + } + } } export default UserService; diff --git a/backend/src/utils/service/emailNotifier.ts b/backend/src/utils/service/emailNotifier.ts index 5de3773..bec7261 100644 --- a/backend/src/utils/service/emailNotifier.ts +++ b/backend/src/utils/service/emailNotifier.ts @@ -1,5 +1,12 @@ import ZohoMailer from "./nodeMailer"; +interface ReminderEmailData { + email: string; + firstName?: string; + reminderType: '7-day' | '14-day' | '30-day' | '60-day'; + daysSinceActivity: number; +} + class EmailNotifier { public static async sendAccountActivationEmail(email: string, link: string) { const message = `Welcome to AURORA. Click on this to activate your account: ${link}`; @@ -9,6 +16,125 @@ class EmailNotifier { await mailer.sendTextEmail(email, subject, message); } + private static getReminderEmailContent(data: ReminderEmailData): { subject: string; message: string } { + const { firstName, reminderType, daysSinceActivity } = data; + const name = firstName ? firstName : "there"; + + switch (reminderType) { + case '7-day': + return { + subject: "We miss you! Come back to AURORA", + message: `Hi ${name}, + +We noticed you haven't been active on AURORA for ${daysSinceActivity} days. We miss you! + +Log back in and continue your progress: ${process.env.AURORA_WEB_APP_BASE_URL} + +Keep learning! +The AURORA Team` + }; + + case '14-day': + return { + subject: "Don't lose your progress - Continue your journey", + message: `Hi ${name}, + +It's been ${daysSinceActivity} days since we last saw you on AURORA. We hope you're doing well! + +Pick up where you left off: ${process.env.AURORA_WEB_APP_BASE_URL} + +We believe in your potential! +The AURORA Team` + }; + + case '30-day': + return { + subject: "Special learning resources waiting for you", + message: `Hi ${name}, + +We haven't seen you on AURORA for ${daysSinceActivity} days, and we want to help you get back on track! + +Your skills are waiting to be unlocked: ${process.env.AURORA_WEB_APP_BASE_URL} + +Let's restart your journey together! +The AURORA Team` + }; + + case '60-day': + return { + subject: "Your journey awaits - Final reminder", + message: `Hi ${name}, + +It's been ${daysSinceActivity} days since you last visited AURORA. We truly miss having you as part of our learning community. + +This is our final reminder, but we want you to know that your account and progress are still here waiting for you. + + +Give AURORA one more try: ${process.env.AURORA_WEB_APP_BASE_URL} + + +Wishing you success in all your endeavors, +The AURORA Team` + }; + + default: + throw new Error(`Unknown reminder type: ${reminderType}`); + } + } + + public static async sendReminderEmail(data: ReminderEmailData) { + try { + const { subject, message } = this.getReminderEmailContent(data); + const mailer = new ZohoMailer(); + + await mailer.sendTextEmail(data.email, subject, message); + console.log(`πŸ“¨ ${data.reminderType} reminder sent to ${data.email}`); + } catch (error) { + console.error(`❌ Failed to send ${data.reminderType} reminder to ${data.email}:`, error); + throw error; + } + } + + public static async sendReminderEmails(reminders: ReminderEmailData[], maxRetries: number = 2, retryIntervalHours: number = 24) { + const results = { + success: 0, + failed: 0, + errors: [] as Array<{ email: string; error: string }> + }; + + for (const reminder of reminders) { + let attempts = 0; + let success = false; + + while (attempts <= maxRetries && !success) { + try { + if (attempts > 0) { + console.log(`πŸ”„ Retry attempt ${attempts} for ${reminder.email}`); + // In a real scenario, you'd want to implement actual delay + // For now, we'll just log the retry attempt + } + + await this.sendReminderEmail(reminder); + results.success++; + success = true; + } catch (error) { + attempts++; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + if (attempts > maxRetries) { + results.failed++; + results.errors.push({ + email: reminder.email, + error: errorMessage + }); + console.error(`❌ Failed to send reminder to ${reminder.email} after ${maxRetries} retries`); + } + } + } + } + + return results; + } } export default EmailNotifier; \ No newline at end of file From f573a0de3c7ad2169a40e0a5a146225c4bce0c31 Mon Sep 17 00:00:00 2001 From: laryhills Date: Thu, 31 Jul 2025 00:17:45 +0100 Subject: [PATCH 2/4] refactor: streamline graceful shutdown handling in server and clean up user reminder job logs --- backend/src/jobs/userReminder.job.ts | 4 ---- backend/src/server.ts | 13 +++++-------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/backend/src/jobs/userReminder.job.ts b/backend/src/jobs/userReminder.job.ts index f4e9cac..294b73a 100644 --- a/backend/src/jobs/userReminder.job.ts +++ b/backend/src/jobs/userReminder.job.ts @@ -116,7 +116,6 @@ class UserReminderJob { console.log(`βœ… Updated reminder timestamps for ${userIds.length} users`); } catch (error) { console.error("❌ Failed to update reminder timestamps:", error); - // Don't throw here as emails were already sent successfully } } @@ -137,13 +136,10 @@ class UserReminderJob { nextRun?: string; }> { try { - // In a real implementation, you might store job execution history - // For now, we'll just check if the services are accessible await NotificationService.getReminderStats(); return { isHealthy: true, - // These would come from job execution history in a real implementation lastRun: new Date(), nextRun: "Based on CRON schedule", }; diff --git a/backend/src/server.ts b/backend/src/server.ts index c0c4649..7bd95c7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,17 +18,14 @@ JobSchedulerService.initializeJobs(); // Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM received, shutting down gracefully'); +const gracefulShutdown = (signal: string) => { + console.log(`${signal} received, shutting down gracefully`); JobSchedulerService.shutdown(); process.exit(0); -}); +}; -process.on('SIGINT', () => { - console.log('SIGINT received, shutting down gracefully'); - JobSchedulerService.shutdown(); - process.exit(0); -}); +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); server.listen(port, () => { console.log(`πŸš€πŸš€πŸš€ Aurora's server is running at http://localhost:${port} πŸš€πŸš€πŸš€`); From cd330b6c963df763a95155146b47aef78d0d5a62 Mon Sep 17 00:00:00 2001 From: laryhills Date: Mon, 4 Aug 2025 19:45:57 +0100 Subject: [PATCH 3/4] refactor: :recycle: applied suggested feedbacks --- backend/src/controllers/auth.controller.ts | 7 ++- backend/src/jobs/userReminder.job.ts | 18 ++++---- backend/src/server.ts | 29 +++++++++--- backend/src/services/jobScheduler.service.ts | 9 ++++ backend/src/services/notification.service.ts | 7 +-- backend/src/services/user.service.ts | 46 ++++++++++++++++++++ backend/src/utils/service/emailNotifier.ts | 31 +++++++++---- 7 files changed, 120 insertions(+), 27 deletions(-) diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index d1ca2e9..bb9da21 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -90,8 +90,11 @@ export const login = asyncHandler(async (req: Request, res: Response) => { const isPasswordValid = await Bcrypt.compare(password, user.password) if (!isPasswordValid) throw new BadRequestError("Invalid credentials") - // Update login and activity timestamps - await UserService.updateLastLogin(user.id) + try { + await UserService.updateLastLogin(user.id) + } catch (err) { + console.error(`Couldn't update last login:`, err) + } const token = Jwt.issue({ id: user.id }, "1d") diff --git a/backend/src/jobs/userReminder.job.ts b/backend/src/jobs/userReminder.job.ts index 294b73a..87a708c 100644 --- a/backend/src/jobs/userReminder.job.ts +++ b/backend/src/jobs/userReminder.job.ts @@ -61,10 +61,14 @@ class UserReminderJob { const emailResults = await EmailNotifier.sendReminderEmails(reminderEmails, maxRetries); // Update lastReminderSent for successfully sent emails - const successfulEmails = reminderEmails.slice(0, emailResults.success); - await this.updateReminderSentTimestamps(successfulEmails.map(email => - candidates.find(c => c.email === email.email)!.id - )); + const successfulEmails = emailResults.successfulEmails || []; + const userIdsToUpdate = successfulEmails + .map(email => candidates.find(c => c.email === email)?.id) + .filter(id => id !== undefined) as string[]; + + if (userIdsToUpdate.length > 0) { + await this.updateReminderSentTimestamps(userIdsToUpdate); + } const executionTime = Date.now() - startTime; @@ -108,11 +112,7 @@ class UserReminderJob { */ private static async updateReminderSentTimestamps(userIds: string[]): Promise { try { - const updatePromises = userIds.map(userId => - UserService.updateReminderSent(userId) - ); - - await Promise.all(updatePromises); + await UserService.updateReminderSentBatch(userIds); console.log(`βœ… Updated reminder timestamps for ${userIds.length} users`); } catch (error) { console.error("❌ Failed to update reminder timestamps:", error); diff --git a/backend/src/server.ts b/backend/src/server.ts index 7bd95c7..fb04316 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,14 +14,33 @@ const port = settings.serverPort || 8000; connectDB(); // Initialize scheduled jobs -JobSchedulerService.initializeJobs(); - +try { + JobSchedulerService.initializeJobs(); + console.log("βœ… Scheduled jobs initialized successfully"); +} catch (error) { + console.error("❌ Failed to initialize scheduled jobs:", error); +} // Graceful shutdown -const gracefulShutdown = (signal: string) => { +const gracefulShutdown = async (signal: string) => { console.log(`${signal} received, shutting down gracefully`); - JobSchedulerService.shutdown(); - process.exit(0); + + // Set a timeout for forced shutdown + const forceShutdown = setTimeout(() => { + console.log("Force shutdown after timeout"); + process.exit(1); + }, 10000); + + try { + await JobSchedulerService.shutdown(); + console.log("Jobs shut down successfully"); + clearTimeout(forceShutdown); + process.exit(0); + } catch (error) { + console.error("Error during shutdown:", error); + clearTimeout(forceShutdown); + process.exit(1); + } }; process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); diff --git a/backend/src/services/jobScheduler.service.ts b/backend/src/services/jobScheduler.service.ts index 08a4a74..1ddde67 100644 --- a/backend/src/services/jobScheduler.service.ts +++ b/backend/src/services/jobScheduler.service.ts @@ -25,6 +25,7 @@ class JobSchedulerService { */ private static scheduleUserReminderJob(): void { const cronSchedule = settings.reminders.cronSchedule; + let isRunning = false; if (!cron.validate(cronSchedule)) { console.error(`❌ Invalid CRON schedule: ${cronSchedule}`); @@ -32,6 +33,12 @@ class JobSchedulerService { } const job = cron.schedule(cronSchedule, async () => { + if (isRunning) { + console.log("⏭️ Skipping user reminder job - previous execution still running"); + return; + } + + isRunning = true; try { console.log("⏰ Running scheduled user reminder job..."); const result = await UserReminderJob.checkInactiveUsers(settings.reminders.maxRetries); @@ -43,6 +50,8 @@ class JobSchedulerService { } } catch (error) { console.error("❌ Scheduled reminder job error:", error); + } finally { + isRunning = false; } }, { timezone: "UTC", diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts index 8358a3d..59cfaf3 100644 --- a/backend/src/services/notification.service.ts +++ b/backend/src/services/notification.service.ts @@ -22,7 +22,7 @@ class NotificationService { */ private static daysBetween(date1: Date, date2: Date): number { const diffTime = Math.abs(date2.getTime() - date1.getTime()); - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return Math.floor(diffTime / (1000 * 60 * 60 * 24)) } /** @@ -104,8 +104,9 @@ class NotificationService { breakdown: Record; }> { try { - const inactiveUsers = await UserService.findInactiveUsers(7); const candidates = await this.findReminderCandidates(); + // Get total inactive count from the candidates or make a separate count query + const totalInactive = await UserService.countInactiveUsers(7); const breakdown = candidates.reduce((acc, candidate) => { acc[candidate.reminderType] = (acc[candidate.reminderType] || 0) + 1; @@ -113,7 +114,7 @@ class NotificationService { }, {} as Record); return { - totalInactive: inactiveUsers.length, + totalInactive, candidatesFound: candidates.length, breakdown, }; diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index 5c84ea2..61cb0fa 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -124,6 +124,36 @@ class UserService { } } + public static async countInactiveUsers(days: number): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + try { + return await prisma.user.count({ + where: { + status: Status.ACTIVE, + isEmailVerified: true, + OR: [ + { + lastActivityAt: { + lte: cutoffDate, + }, + }, + { + lastActivityAt: null, + createdAt: { + lte: cutoffDate, + }, + }, + ], + }, + }); + } catch (error) { + console.error("Failed to count inactive users:", error); + throw new InternalError("Failed to count inactive users"); + } + } + public static async updateReminderSent(userId: string) { try { return await prisma.user.update({ @@ -135,6 +165,22 @@ class UserService { throw new InternalError("Failed to update reminder sent"); } } + + public static async updateReminderSentBatch(userIds: string[]): Promise { + try { + await prisma.$transaction( + userIds.map(userId => + prisma.user.update({ + where: { id: userId }, + data: { lastReminderSent: new Date() } + }) + ) + ); + } catch (error) { + console.error("Failed to batch update reminder sent:", error); + throw new InternalError("Failed to batch update reminder sent"); + } + } } export default UserService; diff --git a/backend/src/utils/service/emailNotifier.ts b/backend/src/utils/service/emailNotifier.ts index bec7261..c14eb9c 100644 --- a/backend/src/utils/service/emailNotifier.ts +++ b/backend/src/utils/service/emailNotifier.ts @@ -8,6 +8,20 @@ interface ReminderEmailData { } class EmailNotifier { + private static getValidatedAppUrl(): string { + const url = process.env.AURORA_WEB_APP_BASE_URL; + if (!url) { + throw new Error('AURORA_WEB_APP_BASE_URL is not configured'); + } + + try { + new URL(url); // Validates URL format + return url; + } catch { + throw new Error('AURORA_WEB_APP_BASE_URL is not a valid URL'); + } + } + public static async sendAccountActivationEmail(email: string, link: string) { const message = `Welcome to AURORA. Click on this to activate your account: ${link}`; const subject = "Activate your account"; @@ -28,7 +42,7 @@ class EmailNotifier { We noticed you haven't been active on AURORA for ${daysSinceActivity} days. We miss you! -Log back in and continue your progress: ${process.env.AURORA_WEB_APP_BASE_URL} +Log back in and continue your progress: ${this.getValidatedAppUrl()} Keep learning! The AURORA Team` @@ -41,7 +55,7 @@ The AURORA Team` It's been ${daysSinceActivity} days since we last saw you on AURORA. We hope you're doing well! -Pick up where you left off: ${process.env.AURORA_WEB_APP_BASE_URL} +Pick up where you left off: ${this.getValidatedAppUrl()} We believe in your potential! The AURORA Team` @@ -54,7 +68,7 @@ The AURORA Team` We haven't seen you on AURORA for ${daysSinceActivity} days, and we want to help you get back on track! -Your skills are waiting to be unlocked: ${process.env.AURORA_WEB_APP_BASE_URL} +Your skills are waiting to be unlocked: ${this.getValidatedAppUrl()} Let's restart your journey together! The AURORA Team` @@ -70,7 +84,7 @@ It's been ${daysSinceActivity} days since you last visited AURORA. We truly miss This is our final reminder, but we want you to know that your account and progress are still here waiting for you. -Give AURORA one more try: ${process.env.AURORA_WEB_APP_BASE_URL} +Give AURORA one more try: ${this.getValidatedAppUrl()} Wishing you success in all your endeavors, @@ -95,11 +109,12 @@ The AURORA Team` } } - public static async sendReminderEmails(reminders: ReminderEmailData[], maxRetries: number = 2, retryIntervalHours: number = 24) { + public static async sendReminderEmails(reminders: ReminderEmailData[], maxRetries: number = 2, retryDelayMs: number = 5000) { const results = { success: 0, failed: 0, - errors: [] as Array<{ email: string; error: string }> + errors: [] as Array<{ email: string; error: string }>, + successfulEmails: [] as string[] }; for (const reminder of reminders) { @@ -110,12 +125,12 @@ The AURORA Team` try { if (attempts > 0) { console.log(`πŸ”„ Retry attempt ${attempts} for ${reminder.email}`); - // In a real scenario, you'd want to implement actual delay - // For now, we'll just log the retry attempt + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } await this.sendReminderEmail(reminder); results.success++; + results.successfulEmails.push(reminder.email); success = true; } catch (error) { attempts++; From 80c6ba991d2141547c4be0e80e4e7a870a840bdd Mon Sep 17 00:00:00 2001 From: Gerson2102 Date: Wed, 27 Aug 2025 11:16:22 -0600 Subject: [PATCH 4/4] Fixing email send issue zeptomail <-> smtp --- backend/src/utils/service/emailNotifier.ts | 2 +- backend/src/utils/service/nodeMailer.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/utils/service/emailNotifier.ts b/backend/src/utils/service/emailNotifier.ts index c14eb9c..df5070e 100644 --- a/backend/src/utils/service/emailNotifier.ts +++ b/backend/src/utils/service/emailNotifier.ts @@ -109,7 +109,7 @@ The AURORA Team` } } - public static async sendReminderEmails(reminders: ReminderEmailData[], maxRetries: number = 2, retryDelayMs: number = 5000) { + public static async sendReminderEmails(reminders: ReminderEmailData[], maxRetries: number = 2, retryDelayMs: number = 1000) { const results = { success: 0, failed: 0, diff --git a/backend/src/utils/service/nodeMailer.ts b/backend/src/utils/service/nodeMailer.ts index b76ccb6..f7557ce 100644 --- a/backend/src/utils/service/nodeMailer.ts +++ b/backend/src/utils/service/nodeMailer.ts @@ -8,9 +8,12 @@ class ZohoMailer { constructor() { this.transporter = nodemailer.createTransport({ - host: "smtp.zeptomail.com", + host: "smtp.gmail.com", port: 587, secure: false, + connectionTimeout: 10000, // 10 seconds + greetingTimeout: 10000, // 10 seconds + socketTimeout: 10000, // 10 seconds auth: { user: serverSettings.email.username, pass: serverSettings.email.password,