From b1342de72262057e9b108a65eb428e7260737754 Mon Sep 17 00:00:00 2001 From: taberah Date: Sun, 22 Feb 2026 17:32:58 +0100 Subject: [PATCH] feat: add automated cliff end email notifications and .gitignore --- .gitignore | 6 + backend/.env.example | 7 + backend/package-lock.json | 373 ++++++++++++++++++++ backend/package.json | 8 +- backend/src/index.js | 10 +- backend/src/models/associations.js | 46 ++- backend/src/models/beneficiary.js | 8 + backend/src/models/index.js | 2 + backend/src/models/notification.js | 62 ++++ backend/src/models/subSchedule.js | 41 ++- backend/src/services/emailService.js | 66 ++++ backend/src/services/notificationService.js | 135 +++++++ 12 files changed, 752 insertions(+), 12 deletions(-) create mode 100644 .gitignore create mode 100644 backend/src/models/notification.js create mode 100644 backend/src/services/emailService.js create mode 100644 backend/src/services/notificationService.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..940faeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.env +.DS_Store +dist/ +build/ +*.log diff --git a/backend/.env.example b/backend/.env.example index 95b35f5..07ba8ba 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 4d0febd..30c1fd9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,13 +13,17 @@ "@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", @@ -933,6 +937,136 @@ "dev": true, "license": "MIT" }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@graphql-tools/merge": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", @@ -1432,6 +1566,104 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -1704,6 +1936,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1721,6 +1962,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@whatwg-node/promise-helpers": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", @@ -2307,6 +2558,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2580,6 +2840,42 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2890,6 +3186,12 @@ "express": ">= 4.11" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3051,6 +3353,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4373,6 +4684,12 @@ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -4407,6 +4724,12 @@ "node": ">=12" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4607,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", @@ -4641,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", @@ -5307,6 +5648,23 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6087,6 +6445,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6150,6 +6514,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.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 ee2c123..9c3cab2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -34,6 +34,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) => { @@ -277,7 +278,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 @@ -317,6 +318,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 e63f03e..2fca8eb 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -5,6 +5,7 @@ const SubSchedule = require('./subSchedule'); const TVL = require('./tvl'); const Beneficiary = require('./beneficiary'); const Organization = require('./organization'); +const Notification = require('./notification'); const models = { ClaimsHistory, @@ -13,6 +14,7 @@ const models = { TVL, Beneficiary, Organization, + Notification, 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 45384af..aa13cc1 100644 --- a/backend/src/models/subSchedule.js +++ b/backend/src/models/subSchedule.js @@ -16,22 +16,45 @@ const SubSchedule = sequelize.define('SubSchedule', { }, onUpdate: 'CASCADE', onDelete: 'CASCADE', - }, top_up_amount: { type: DataTypes.DECIMAL(36, 18), allowNull: false, comment: 'Amount of tokens added in this top-up', }, - + top_up_transaction_hash: { + type: DataTypes.STRING(66), + allowNull: false, + unique: true, }, - created_at: { + top_up_timestamp: { type: DataTypes.DATE, - defaultValue: DataTypes.NOW, + allowNull: false, + }, + cliff_duration: { + type: DataTypes.INTEGER, + allowNull: true, }, - updated_at: { + 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', @@ -43,7 +66,11 @@ const SubSchedule = sequelize.define('SubSchedule', { fields: ['vault_id'], }, { - + fields: ['top_up_transaction_hash'], + unique: true, + }, + { + fields: ['cliff_date'], }, ], }); 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();