diff --git a/.gitignore b/.gitignore index 3c3629e..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +0,0 @@ -node_modules diff --git a/backend/.env.example b/backend/.env.example index ba4d91e..46f6f45 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,3 +25,10 @@ REDIS_URL=redis://localhost:6379 # Slack Webhook Configuration SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL + +# Email Configuration +EMAIL_HOST=smtp.mailtrap.io +EMAIL_PORT=2525 +EMAIL_USER=your-email-user +EMAIL_PASS=your-email-password +EMAIL_FROM=no-reply@vestingvault.com diff --git a/backend/package-lock.json b/backend/package-lock.json index ea70a18..30c1fd9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,8 @@ "graphql": "^16.8.1", "graphql-subscriptions": "^2.0.0", "graphql-ws": "^5.14.3", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.1", "pg": "^8.11.3", "redis": "^4.6.12", "sequelize": "^6.35.2", @@ -4928,6 +4930,15 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, + "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-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -4962,6 +4973,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index 7216b2d..32f363f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,19 +15,21 @@ "@graphql-tools/schema": "^10.0.2", "axios": "^1.6.2", "cors": "^2.8.5", + "discord.js": "^14.14.1", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.1.5", "graphql": "^16.8.1", "graphql-subscriptions": "^2.0.0", "graphql-ws": "^5.14.3", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.1", "pg": "^8.11.3", + "redis": "^4.6.12", "sequelize": "^6.35.2", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", - "ws": "^8.14.2", - "discord.js": "^14.14.1", - "redis": "^4.6.12" + "ws": "^8.14.2" }, "devDependencies": { "jest": "^29.7.0", diff --git a/backend/src/index.js b/backend/src/index.js index d6a5ee7..fe83212 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -55,6 +55,7 @@ const discordBotService = require('./services/discordBotService'); const cacheService = require('./services/cacheService'); const tvlService = require('./services/tvlService'); const vaultExportService = require('./services/vaultExportService'); +const notificationService = require('./services/notificationService'); // Routes app.get('/', (req, res) => { @@ -308,7 +309,7 @@ const startServer = async () => { await sequelize.authenticate(); console.log('Database connection established successfully.'); - await sequelize.sync(); + await sequelize.sync({ alter: true }); console.log('Database synchronized successfully.'); // Initialize Redis Cache @@ -348,6 +349,13 @@ const startServer = async () => { console.log('Continuing without Discord bot...'); } + // Initialize Notification Service (Cron Job) + try { + notificationService.start(); + } catch (notificationError) { + console.error('Failed to start Notification Service:', notificationError); + } + // Start the HTTP server httpServer.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/backend/src/models/associations.js b/backend/src/models/associations.js index 760b14d..8b8a336 100644 --- a/backend/src/models/associations.js +++ b/backend/src/models/associations.js @@ -1,4 +1,4 @@ -const { Vault, SubSchedule, Beneficiary, Organization } = require('../models'); +const { Vault, SubSchedule, Beneficiary, Organization, Notification } = require('../models'); // Setup model associations Vault.hasMany(SubSchedule, { @@ -23,6 +23,27 @@ Beneficiary.belongsTo(Vault, { as: 'vault', }); +Beneficiary.hasMany(Notification, { + foreignKey: 'beneficiary_id', + as: 'notifications', + onDelete: 'CASCADE', +}); + +Notification.belongsTo(Beneficiary, { + foreignKey: 'beneficiary_id', + as: 'beneficiary', +}); + +Notification.belongsTo(Vault, { + foreignKey: 'vault_id', + as: 'vault', +}); + +Notification.belongsTo(SubSchedule, { + foreignKey: 'sub_schedule_id', + as: 'subSchedule', +}); + // Add associate methods to models Vault.associate = function(models) { Vault.hasMany(models.SubSchedule, { @@ -60,6 +81,28 @@ Beneficiary.associate = function(models) { foreignKey: 'vault_id', as: 'vault', }); + + Beneficiary.hasMany(models.Notification, { + foreignKey: 'beneficiary_id', + as: 'notifications', + }); +}; + +Notification.associate = function(models) { + Notification.belongsTo(models.Beneficiary, { + foreignKey: 'beneficiary_id', + as: 'beneficiary', + }); + + Notification.belongsTo(models.Vault, { + foreignKey: 'vault_id', + as: 'vault', + }); + + Notification.belongsTo(models.SubSchedule, { + foreignKey: 'sub_schedule_id', + as: 'subSchedule', + }); }; module.exports = { @@ -67,4 +110,5 @@ module.exports = { SubSchedule, Beneficiary, Organization, + Notification, }; diff --git a/backend/src/models/beneficiary.js b/backend/src/models/beneficiary.js index 40f0c43..bcfbeb2 100644 --- a/backend/src/models/beneficiary.js +++ b/backend/src/models/beneficiary.js @@ -22,6 +22,14 @@ const Beneficiary = sequelize.define('Beneficiary', { allowNull: false, comment: 'Beneficiary wallet address', }, + email: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Beneficiary email address', + validate: { + isEmail: true, + }, + }, total_allocated: { type: DataTypes.DECIMAL(36, 18), allowNull: false, diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 785059b..200bfd5 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -6,6 +6,7 @@ const SubSchedule = require('./subSchedule'); const TVL = require('./tvl'); const Beneficiary = require('./beneficiary'); const Organization = require('./organization'); +const Notification = require('./notification'); const { Token, initTokenModel } = require('./token'); const { OrganizationWebhook, initOrganizationWebhookModel } = require('./organizationWebhook'); @@ -23,8 +24,7 @@ const models = { TVL, Beneficiary, Organization, - Token, - OrganizationWebhook, + sequelize, }; diff --git a/backend/src/models/notification.js b/backend/src/models/notification.js new file mode 100644 index 0000000..8a49543 --- /dev/null +++ b/backend/src/models/notification.js @@ -0,0 +1,62 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../database/connection'); + +const Notification = sequelize.define('Notification', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + beneficiary_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'beneficiaries', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + vault_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'vaults', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + sub_schedule_id: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'sub_schedules', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + type: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Type of notification, e.g., CLIFF_PASSED', + }, + sent_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'notifications', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['beneficiary_id', 'vault_id', 'sub_schedule_id', 'type'], + unique: true, + }, + ], +}); + +module.exports = Notification; diff --git a/backend/src/models/subSchedule.js b/backend/src/models/subSchedule.js index 431cdc1..054fc28 100644 --- a/backend/src/models/subSchedule.js +++ b/backend/src/models/subSchedule.js @@ -22,13 +22,34 @@ const SubSchedule = sequelize.define('SubSchedule', { allowNull: false, comment: 'Amount of tokens added in this top-up', }, - created_at: { + type: DataTypes.DATE, - defaultValue: DataTypes.NOW, + allowNull: false, }, - updated_at: { + cliff_duration: { + type: DataTypes.INTEGER, + allowNull: true, + }, + cliff_date: { type: DataTypes.DATE, - defaultValue: DataTypes.NOW, + allowNull: true, + }, + vesting_start_date: { + type: DataTypes.DATE, + allowNull: false, + }, + vesting_duration: { + type: DataTypes.INTEGER, + allowNull: false, + }, + amount_released: { + type: DataTypes.DECIMAL(36, 18), + allowNull: false, + defaultValue: 0, + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, }, }, { tableName: 'sub_schedules', @@ -39,6 +60,7 @@ const SubSchedule = sequelize.define('SubSchedule', { { fields: ['vault_id'], }, + ], }); diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js new file mode 100644 index 0000000..3a456e9 --- /dev/null +++ b/backend/src/services/emailService.js @@ -0,0 +1,66 @@ +const nodemailer = require('nodemailer'); + +class EmailService { + constructor() { + this.transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST || 'smtp.mailtrap.io', + port: process.env.EMAIL_PORT || 2525, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + } + + /** + * Send an email + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} text - Email body (plain text) + * @param {string} html - Email body (HTML) + * @returns {Promise} Success status + */ + async sendEmail(to, subject, text, html) { + try { + if (!to) { + console.warn('No recipient email provided, skipping email notification'); + return false; + } + + if (!process.env.EMAIL_USER || !process.env.EMAIL_PASS) { + console.warn('Email credentials not set, skipping email notification'); + return false; + } + + const info = await this.transporter.sendMail({ + from: `"Vesting Vault" <${process.env.EMAIL_FROM || 'no-reply@vestingvault.com'}>`, + to, + subject, + text, + html, + }); + + console.log('Email sent: %s', info.messageId); + return true; + } catch (error) { + console.error('Error sending email:', error.message); + return false; + } + } + + /** + * Send cliff passed notification + * @param {string} to - Recipient email + * @param {string} amount - Claimable amount + * @returns {Promise} Success status + */ + async sendCliffPassedEmail(to, amount) { + const subject = 'Your Cliff has passed!'; + const text = `Your Cliff has passed! You can now claim ${parseFloat(amount).toLocaleString()} tokens.`; + const html = `

Your Cliff has passed! You can now claim ${parseFloat(amount).toLocaleString()} tokens.

`; + + return await this.sendEmail(to, subject, text, html); + } +} + +module.exports = new EmailService(); diff --git a/backend/src/services/notificationService.js b/backend/src/services/notificationService.js new file mode 100644 index 0000000..b5e2b99 --- /dev/null +++ b/backend/src/services/notificationService.js @@ -0,0 +1,135 @@ +const { Vault, SubSchedule, Beneficiary, Notification, sequelize } = require('../models'); +const { Op } = require('sequelize'); +const emailService = require('./emailService'); +const cron = require('node-cron'); + +class NotificationService { + constructor() { + this.cronJob = null; + } + + /** + * Start the notification cron job + */ + start() { + // Run every hour + this.cronJob = cron.schedule('0 * * * *', async () => { + console.log('Running cliff notification cron job...'); + await this.checkAndNotifyCliffs(); + }); + console.log('Cliff notification cron job started.'); + } + + /** + * Check all vaults and sub-schedules for passed cliffs and notify beneficiaries + */ + async checkAndNotifyCliffs() { + try { + const now = new Date(); + + // 1. Check Vault cliffs + const vaultsWithCliffPassed = await Vault.findAll({ + where: { + cliff_date: { + [Op.lte]: now, + [Op.ne]: null + }, + is_active: true + }, + include: [{ + model: Beneficiary, + as: 'beneficiaries', + where: { + email: { [Op.ne]: null } + } + }] + }); + + for (const vault of vaultsWithCliffPassed) { + for (const beneficiary of vault.beneficiaries) { + await this.notifyIfRequired(beneficiary, vault, null, 'CLIFF_PASSED', vault.total_amount); + } + } + + // 2. Check SubSchedule cliffs + const subSchedulesWithCliffPassed = await SubSchedule.findAll({ + where: { + cliff_date: { + [Op.lte]: now, + [Op.ne]: null + }, + is_active: true + }, + include: [{ + model: Vault, + as: 'vault', + include: [{ + model: Beneficiary, + as: 'beneficiaries', + where: { + email: { [Op.ne]: null } + } + }] + }] + }); + + for (const subSchedule of subSchedulesWithCliffPassed) { + for (const beneficiary of subSchedule.vault.beneficiaries) { + await this.notifyIfRequired(beneficiary, subSchedule.vault, subSchedule, 'CLIFF_PASSED', subSchedule.top_up_amount); + } + } + + } catch (error) { + console.error('Error in checkAndNotifyCliffs:', error); + } + } + + /** + * Notify if not already notified + * @param {Object} beneficiary - Beneficiary model instance + * @param {Object} vault - Vault model instance + * @param {Object|null} subSchedule - SubSchedule model instance or null + * @param {string} type - Notification type + * @param {string} amount - Claimable amount + */ + async notifyIfRequired(beneficiary, vault, subSchedule, type, amount) { + const transaction = await sequelize.transaction(); + try { + // Check if notification already sent + const existingNotification = await Notification.findOne({ + where: { + beneficiary_id: beneficiary.id, + vault_id: vault.id, + sub_schedule_id: subSchedule ? subSchedule.id : null, + type + }, + transaction + }); + + if (!existingNotification) { + console.log(`Sending ${type} email to ${beneficiary.email} for vault ${vault.vault_address}`); + + const emailSent = await emailService.sendCliffPassedEmail(beneficiary.email, amount); + + if (emailSent) { + await Notification.create({ + beneficiary_id: beneficiary.id, + vault_id: vault.id, + sub_schedule_id: subSchedule ? subSchedule.id : null, + type, + sent_at: new Date() + }, { transaction }); + + console.log(`Notification recorded in DB for ${beneficiary.email}`); + } + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error(`Failed to process notification for beneficiary ${beneficiary.id}:`, error); + } + } +} + +module.exports = new NotificationService();