Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 0 additions & 5 deletions backend/logs/error.log
Original file line number Diff line number Diff line change
@@ -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 (<anonymous>)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:71\n at new Promise (<anonymous>)\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 (<anonymous>)\n at C:\\projects\\others\\onlyDust\\AURORA\\AURORA-Backend\\backend\\src\\controllers\\question.controller.ts:8:71\n at new Promise (<anonymous>)\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 (<anonymous>)\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 (<anonymous>)\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 (<anonymous>)\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)"}
17 changes: 17 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -71,4 +74,4 @@
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
}
}
}
29 changes: 16 additions & 13 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions backend/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
18 changes: 18 additions & 0 deletions backend/src/core/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
154 changes: 154 additions & 0 deletions backend/src/jobs/userReminder.job.ts
Original file line number Diff line number Diff line change
@@ -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<JobResult> {
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<void> {
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<JobResult> {
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;
3 changes: 3 additions & 0 deletions backend/src/middlewares/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} πŸš€πŸš€πŸš€`);
});
Loading