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 3c90c35..95857b5 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", @@ -2037,6 +2039,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", @@ -6020,6 +6028,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 061a175..8112d1a 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", @@ -71,4 +74,4 @@ "ts-node": "^10.9.2", "typescript": "^5.8.3" } -} +} \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5a61f5b..b357ed5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,19 +9,22 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique - password String - isEmailVerified Boolean @default(false) - firstName String? - lastName String? - phoneNumber String? - profileImage String? - status Status @default(INACTIVE) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - wallet Wallet? - questions Question[] + id String @id @default(uuid()) + email String @unique + password String + isEmailVerified Boolean @default(false) + firstName String? + lastName String? + phoneNumber String? + profileImage String? + status Status @default(INACTIVE) + lastLoginAt DateTime? + lastActivityAt DateTime? + lastReminderSent DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + wallet Wallet? + questions Question[] } model Wallet { diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 0349f37..585aaf6 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -88,6 +88,12 @@ export const login = asyncHandler(async (req: Request, res: Response) => { const isPasswordValid = await Bcrypt.compare(password, user.password) if (!isPasswordValid) throw new BadRequestError("Invalid credentials") + try { + await UserService.updateLastLogin(user.id) + } catch (err) { + console.error(`Couldn't update last login:`, err) + } + 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/jobs/userReminder.job.ts b/backend/src/jobs/userReminder.job.ts new file mode 100644 index 0000000..87a708c --- /dev/null +++ b/backend/src/jobs/userReminder.job.ts @@ -0,0 +1,154 @@ +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 = 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; + + 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 { + await UserService.updateReminderSentBatch(userIds); + console.log(`βœ… Updated reminder timestamps for ${userIds.length} users`); + } catch (error) { + console.error("❌ Failed to update reminder timestamps:", error); + } + } + + /** + * 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 { + await NotificationService.getReminderStats(); + + return { + isHealthy: true, + 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 fdc9181..7fd3d6b 100644 --- a/backend/src/middlewares/authentication.ts +++ b/backend/src/middlewares/authentication.ts @@ -26,6 +26,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..fb04316 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,14 +3,49 @@ 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 +try { + JobSchedulerService.initializeJobs(); + console.log("βœ… Scheduled jobs initialized successfully"); +} catch (error) { + console.error("❌ Failed to initialize scheduled jobs:", error); +} + +// Graceful shutdown +const gracefulShutdown = async (signal: string) => { + console.log(`${signal} received, shutting down gracefully`); + + // 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')); +process.on('SIGINT', () => gracefulShutdown('SIGINT')); + 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..1ddde67 --- /dev/null +++ b/backend/src/services/jobScheduler.service.ts @@ -0,0 +1,132 @@ +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; + let isRunning = false; + + if (!cron.validate(cronSchedule)) { + console.error(`❌ Invalid CRON schedule: ${cronSchedule}`); + return; + } + + 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); + + 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); + } finally { + isRunning = false; + } + }, { + 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..59cfaf3 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,128 @@ +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.floor(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 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; + return acc; + }, {} as Record); + + return { + totalInactive, + 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 308bc28..e449fe6 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -61,6 +61,130 @@ 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 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({ + 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"); + } + } + + 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 5de3773..df5070e 100644 --- a/backend/src/utils/service/emailNotifier.ts +++ b/backend/src/utils/service/emailNotifier.ts @@ -1,6 +1,27 @@ import ZohoMailer from "./nodeMailer"; +interface ReminderEmailData { + email: string; + firstName?: string; + reminderType: '7-day' | '14-day' | '30-day' | '60-day'; + daysSinceActivity: number; +} + 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"; @@ -9,6 +30,126 @@ 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: ${this.getValidatedAppUrl()} + +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: ${this.getValidatedAppUrl()} + +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: ${this.getValidatedAppUrl()} + +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: ${this.getValidatedAppUrl()} + + +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, retryDelayMs: number = 1000) { + const results = { + success: 0, + failed: 0, + errors: [] as Array<{ email: string; error: string }>, + successfulEmails: [] as 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}`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + + await this.sendReminderEmail(reminder); + results.success++; + results.successfulEmails.push(reminder.email); + 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 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,