From 634a324ba4a7c83cd917033798d85f26ad06f570 Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Sun, 1 Dec 2024 17:35:33 +0530 Subject: [PATCH 1/6] Update README with Docker setup instructions and clean up init-db script --- README.md | 22 ++++++++++++++++++++++ init-db.sh | 1 - 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d095ca3..539c4bf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,26 @@ Follow these steps to get started with the ScholarX backend: 7. Open your web browser and navigate to `http://localhost:${server_port}` to access the running server. +Docker Setup +------------------------------------- + +You can also run the ScholaX backend using Docker. Follow these setps: + +1. Ensure you have Docker and Docker Compose installed on you machine. + +2. Build and run the Docker constainers. + +```bash +docker compose up --build +``` +3. The application will be available at `http://localhost:${server_port}`. + +4. To stop the containers, run: + +```bash +docker compose down +``` + Database Configuration and Migrations ------------------------------------- @@ -229,6 +249,8 @@ We appreciate your interest in ScholarX. Happy contributing! If you have any que 8. Verify it from your account. + + ## Project Structure Here's an overview of the project structure: diff --git a/init-db.sh b/init-db.sh index d61afdf..a703458 100755 --- a/init-db.sh +++ b/init-db.sh @@ -6,7 +6,6 @@ echo "Database is ready. Running migrations..." # Run the migrations npm run sync:db -npm run migration:generate npm run seed echo "Migrations complete. Database is ready." From b36f4f8e97fab631e11a35caeeac659ef46f4f60 Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Sun, 1 Dec 2024 20:01:37 +0530 Subject: [PATCH 2/6] Remove unnecessary blank lines from README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 539c4bf..7b1b10a 100644 --- a/README.md +++ b/README.md @@ -249,8 +249,6 @@ We appreciate your interest in ScholarX. Happy contributing! If you have any que 8. Verify it from your account. - - ## Project Structure Here's an overview of the project structure: From 7ab1ee6f6d4cb866056e20cd940fe9d41aa79b3b Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Mon, 2 Dec 2024 10:36:09 +0530 Subject: [PATCH 3/6] Clarify Docker setup instructions in README and fix typos --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7b1b10a..4efc102 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,14 @@ Follow these steps to get started with the ScholarX backend: 7. Open your web browser and navigate to `http://localhost:${server_port}` to access the running server. -Docker Setup +Docker Setup (optional) ------------------------------------- -You can also run the ScholaX backend using Docker. Follow these setps: +Alternatively you can use Docker to run ScholarX. Follow these setps: 1. Ensure you have Docker and Docker Compose installed on you machine. -2. Build and run the Docker constainers. +2. Build and run the Docker containers. ```bash docker compose up --build From 0e05f3b3884874a91822a56dfcb68dfc4082798b Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Tue, 10 Dec 2024 21:34:08 +0530 Subject: [PATCH 4/6] Add reminder functionality with cron job and related entities - Introduced MonthlyReminder entity and ReminderStatus enum - Implemented ReminderCronService for scheduled reminder processing - Updated Mentee entity to include reminders relationship - Enhanced init-db.sh to check for existing data before seeding - Added postgresql-client installation in Dockerfile - Updated docker-compose.yml for SMTP_PASSWORD environment variable - Added getReminderEmailContent utility function for email content generation --- Dockerfile | 4 + docker-compose.yml | 9 +- init-db.sh | 22 +- package-lock.json | 39 +++- package.json | 2 + src/app.ts | 8 + src/entities/mentee.entity.ts | 8 +- src/entities/reminder.entity.ts | 29 +++ src/enums/index.ts | 9 + .../1727197270336-monthly-checking-tags.ts | 13 -- .../1727636762101-monthlychecking.ts | 13 -- src/migrations/1733219895951-reminders.ts | 113 ++++++++++ src/routes/admin/admin.route.ts | 1 + src/services/admin/reminder.service.test.ts | 210 ++++++++++++++++++ src/services/admin/reminder.service.ts | 124 +++++++++++ src/services/cron/reminder.cron.ts | 50 +++++ src/types.ts | 4 + src/utils.ts | 18 ++ 18 files changed, 634 insertions(+), 42 deletions(-) create mode 100644 src/entities/reminder.entity.ts delete mode 100644 src/migrations/1727197270336-monthly-checking-tags.ts delete mode 100644 src/migrations/1727636762101-monthlychecking.ts create mode 100644 src/migrations/1733219895951-reminders.ts create mode 100644 src/services/admin/reminder.service.test.ts create mode 100644 src/services/admin/reminder.service.ts create mode 100644 src/services/cron/reminder.cron.ts diff --git a/Dockerfile b/Dockerfile index a7efab9..dd74b11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ # Use an official Node.js runtime as the base image FROM node:18 + +# Install postgresql-client +RUN apt-get update && apt-get install -y postgresql-client + # Set the working directory in the Docker container to /app WORKDIR /app/src diff --git a/docker-compose.yml b/docker-compose.yml index 6fed9f1..f97dd5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: - CLIENT_URL=${CLIENT_URL} - IMG_HOST=${IMG_HOST} - SMTP_MAIL=${SMTP_MAIL} - - SMTP_PASS=${SMTP_PASS} + - SMTP_PASSWORD=${SMTP_PASSWORD} command: ["sh", "/app/src/init-db.sh"] db: image: postgres:15 @@ -31,4 +31,9 @@ services: environment: - POSTGRES_USER=${DB_USER} - POSTGRES_PASSWORD=${DB_PASSWORD} - - POSTGRES_DB=${DB_NAME} \ No newline at end of file + - POSTGRES_DB=${DB_NAME} + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/init-db.sh b/init-db.sh index a703458..af80df1 100755 --- a/init-db.sh +++ b/init-db.sh @@ -1,15 +1,29 @@ +#!/bin/sh + # This is a script to initialize the database for the first time when the container is started. -# It will wait for the database to be ready before running the migrations. -# Wait for the + +# Wait for the database to be ready +until psql "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" -c '\q'; do + echo "Waiting for database connection..." + sleep 5 +done echo "Database is ready. Running migrations..." # Run the migrations npm run sync:db -npm run seed + +# Check if the database is already populated +SEED_CHECK=$(psql "postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}" -tAc "SELECT COUNT(*) FROM profile;") + +if [ "$SEED_CHECK" -eq "0" ]; then + echo "Database is empty. Running seed script..." + npm run seed +else + echo "Database already contains data. Skipping seed script." +fi echo "Migrations complete. Database is ready." # Start the application - npm run dev diff --git a/package-lock.json b/package-lock.json index 32cadd7..a0c29dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", "nodemailer": "^6.9.13", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", @@ -43,6 +44,7 @@ "@types/jsonwebtoken": "^9.0.2", "@types/multer": "^1.4.11", "@types/node": "^20.1.4", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", @@ -1630,15 +1632,6 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "peer": true }, - "node_modules/@tsed/common/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@tsed/core": { "version": "7.57.1", "resolved": "https://registry.npmjs.org/@tsed/core/-/core-7.57.1.tgz", @@ -2267,6 +2260,13 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "6.4.15", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", @@ -8174,6 +8174,18 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", @@ -10763,6 +10775,15 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index db302f9..0d95d35 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "express": "^4.18.2", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", "nodemailer": "^6.9.13", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", @@ -56,6 +57,7 @@ "@types/jsonwebtoken": "^9.0.2", "@types/multer": "^1.4.11", "@types/node": "^20.1.4", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.12", "@types/passport-google-oauth20": "^2.0.14", diff --git a/src/app.ts b/src/app.ts index 1fa5cbc..bb08b54 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,7 @@ import mentorRouter from './routes/mentor/mentor.route' import profileRouter from './routes/profile/profile.route' import path from 'path' import countryRouter from './routes/country/country.route' +import { ReminderCronService } from './services/cron/reminder.cron' const app = express() const staticFolder = 'uploads' @@ -74,4 +75,11 @@ export const startServer = async (port: number): Promise => { } } +const reminderCron = new ReminderCronService() +reminderCron.start() + +process.on('SIGTERM', () => { + reminderCron.stop() +}) + export default startServer diff --git a/src/entities/mentee.entity.ts b/src/entities/mentee.entity.ts index dbfbf5b..bf2cd3e 100644 --- a/src/entities/mentee.entity.ts +++ b/src/entities/mentee.entity.ts @@ -5,6 +5,7 @@ import { MenteeApplicationStatus, StatusUpdatedBy } from '../enums' import BaseEntity from './baseEntity' import { UUID } from 'typeorm/driver/mongodb/bson.typings' import MonthlyCheckIn from './checkin.entity' +import { MonthlyReminder } from './reminder.entity' @Entity('mentee') class Mentee extends BaseEntity { @@ -39,12 +40,16 @@ class Mentee extends BaseEntity { @OneToMany(() => MonthlyCheckIn, (checkIn) => checkIn.mentee) checkIns?: MonthlyCheckIn[] + @OneToMany(() => MonthlyReminder, (reminder) => reminder.mentee) + reminders?: MonthlyReminder[] + constructor( state: MenteeApplicationStatus, application: Record, profile: profileEntity, mentor: Mentor, - checkIns?: MonthlyCheckIn[] + checkIns?: MonthlyCheckIn[], + reminders?: MonthlyReminder[] ) { super() this.state = state || MenteeApplicationStatus.PENDING @@ -52,6 +57,7 @@ class Mentee extends BaseEntity { this.profile = profile this.mentor = mentor this.checkIns = checkIns + this.reminders = reminders } } diff --git a/src/entities/reminder.entity.ts b/src/entities/reminder.entity.ts new file mode 100644 index 0000000..bbd7c43 --- /dev/null +++ b/src/entities/reminder.entity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, ManyToOne } from 'typeorm' +import BaseEntity from './baseEntity' +import { ReminderStatus } from '../enums' +import Mentee from './mentee.entity' + +@Entity('monthly_reminders') +export class MonthlyReminder extends BaseEntity { + @ManyToOne(() => Mentee, (mentee) => mentee.reminders) + mentee!: Mentee + + @Column({ + type: 'enum', + enum: ReminderStatus, + default: ReminderStatus.PENDING + }) + status!: ReminderStatus + + @Column({ type: 'text', nullable: true }) + lastError!: string + + @Column({ type: 'int', default: 0 }) + remindersSent!: number + + @Column({ type: 'timestamp', nullable: true }) + nextReminderDate!: Date | null + + @Column({ type: 'timestamp', nullable: true }) + lastSentDate!: Date | null +} diff --git a/src/enums/index.ts b/src/enums/index.ts index 6a84e18..b820b15 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -27,3 +27,12 @@ export enum StatusUpdatedBy { ADMIN = 'admin', MENTOR = 'mentor' } + +export enum ReminderStatus { + PENDING = 'pending', + SENT = 'sent', + FAILED = 'failed', + SCHEDULED = 'scheduled', + COMPLETED = 'completed', + SENDING = 'sending' +} diff --git a/src/migrations/1727197270336-monthly-checking-tags.ts b/src/migrations/1727197270336-monthly-checking-tags.ts deleted file mode 100644 index b2fbd59..0000000 --- a/src/migrations/1727197270336-monthly-checking-tags.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type MigrationInterface, type QueryRunner } from 'typeorm' - -export class MonthlyCheckingTags1727197270336 implements MigrationInterface { - name = 'MonthlyCheckingTags1727197270336' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "monthly-check-in" ADD "tags" json`) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "monthly-check-in" DROP COLUMN "tags"`) - } -} diff --git a/src/migrations/1727636762101-monthlychecking.ts b/src/migrations/1727636762101-monthlychecking.ts deleted file mode 100644 index 030e5fb..0000000 --- a/src/migrations/1727636762101-monthlychecking.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type MigrationInterface, type QueryRunner } from 'typeorm' - -export class Monthlychecking1727636762101 implements MigrationInterface { - name = 'Monthlychecking1727636762101' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "monthly-check-in" DROP COLUMN "tags"`) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "monthly-check-in" ADD "tags" json`) - } -} diff --git a/src/migrations/1733219895951-reminders.ts b/src/migrations/1733219895951-reminders.ts new file mode 100644 index 0000000..38a9671 --- /dev/null +++ b/src/migrations/1733219895951-reminders.ts @@ -0,0 +1,113 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm' + +export class Reminders1733219895951 implements MigrationInterface { + name = 'Reminders1733219895951' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "monthly-check-in" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "title" text NOT NULL, "generalUpdatesAndFeedback" text NOT NULL, "progressTowardsGoals" text NOT NULL, "mediaContentLinks" json, "mentorFeedback" text, "isCheckedByMentor" boolean NOT NULL DEFAULT false, "mentorCheckedDate" TIMESTAMP, "checkInDate" TIMESTAMP NOT NULL DEFAULT now(), "menteeId" uuid, CONSTRAINT "PK_44f1414b858e3eb6b8aacee7fbe" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TYPE "public"."monthly_reminders_status_enum" AS ENUM('pending', 'sent', 'failed', 'scheduled', 'completed', 'sending')` + ) + await queryRunner.query( + `CREATE TABLE "monthly_reminders" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "status" "public"."monthly_reminders_status_enum" NOT NULL DEFAULT 'pending', "lastError" text, "remindersSent" integer NOT NULL DEFAULT '0', "nextReminderDate" TIMESTAMP, "lastSentDate" TIMESTAMP, "menteeUuid" uuid, CONSTRAINT "PK_f3b22313af4df0cb9cf9c41c681" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TYPE "public"."mentee_state_enum" AS ENUM('pending', 'rejected', 'approved', 'completed', 'revoked')` + ) + await queryRunner.query( + `CREATE TYPE "public"."mentee_status_updated_by_enum" AS ENUM('admin', 'mentor')` + ) + await queryRunner.query( + `CREATE TABLE "mentee" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "state" "public"."mentee_state_enum" NOT NULL DEFAULT 'pending', "status_updated_by" "public"."mentee_status_updated_by_enum", "status_updated_date" TIMESTAMP, "application" json NOT NULL, "certificate_id" uuid, "journal" character varying, "profileUuid" uuid, "mentorUuid" uuid, CONSTRAINT "PK_694f9d45c4a2a5bd2e4158df6a8" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TYPE "public"."profile_type_enum" AS ENUM('default', 'admin')` + ) + await queryRunner.query( + `CREATE TABLE "profile" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "primary_email" character varying(255) NOT NULL, "first_name" character varying(255) NOT NULL, "last_name" character varying(255), "image_url" character varying(255) NOT NULL, "type" "public"."profile_type_enum" NOT NULL DEFAULT 'default', "password" character varying(255) NOT NULL, CONSTRAINT "UQ_def641b0892f8d810007e362eb0" UNIQUE ("primary_email"), CONSTRAINT "PK_fab5f83a1cc8ebe0076c733fd85" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TABLE "country" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "code" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_4e06beff3ecfb1a974312fe536d" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TYPE "public"."mentor_state_enum" AS ENUM('pending', 'rejected', 'approved')` + ) + await queryRunner.query( + `CREATE TABLE "mentor" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "state" "public"."mentor_state_enum" NOT NULL DEFAULT 'pending', "application" json NOT NULL, "availability" boolean NOT NULL, "categoryUuid" uuid, "profileUuid" uuid, "countryUuid" uuid, CONSTRAINT "PK_50288edf84756228c143608b2be" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TABLE "category" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category" character varying(255) NOT NULL, CONSTRAINT "PK_86ee096735ccbfa3fd319af1833" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `CREATE TYPE "public"."email_state_enum" AS ENUM('sent', 'delivered', 'failed')` + ) + await queryRunner.query( + `CREATE TABLE "email" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient" character varying(255) NOT NULL, "subject" character varying(255) NOT NULL, "content" character varying NOT NULL, "state" "public"."email_state_enum" NOT NULL, CONSTRAINT "PK_a6db4f191b9a83ca3c8f4149d13" PRIMARY KEY ("uuid"))` + ) + await queryRunner.query( + `ALTER TABLE "monthly-check-in" ADD CONSTRAINT "FK_6de642444085d9599d3f260d566" FOREIGN KEY ("menteeId") REFERENCES "mentee"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "monthly_reminders" ADD CONSTRAINT "FK_3c019691242e748da81b48747ff" FOREIGN KEY ("menteeUuid") REFERENCES "mentee"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "mentee" ADD CONSTRAINT "FK_f671cf2220d1bd0621a1a5e92e7" FOREIGN KEY ("profileUuid") REFERENCES "profile"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "mentee" ADD CONSTRAINT "FK_1fd04826f894fd63a0ce080f6e4" FOREIGN KEY ("mentorUuid") REFERENCES "mentor"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "mentor" ADD CONSTRAINT "FK_59a1e655aa15be5f068a11f0d23" FOREIGN KEY ("categoryUuid") REFERENCES "category"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "mentor" ADD CONSTRAINT "FK_ee49a3436192915d20e07378f16" FOREIGN KEY ("profileUuid") REFERENCES "profile"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "mentor" ADD CONSTRAINT "FK_3302c22eb1636f239d605eb61c3" FOREIGN KEY ("countryUuid") REFERENCES "country"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "mentor" DROP CONSTRAINT "FK_3302c22eb1636f239d605eb61c3"` + ) + await queryRunner.query( + `ALTER TABLE "mentor" DROP CONSTRAINT "FK_ee49a3436192915d20e07378f16"` + ) + await queryRunner.query( + `ALTER TABLE "mentor" DROP CONSTRAINT "FK_59a1e655aa15be5f068a11f0d23"` + ) + await queryRunner.query( + `ALTER TABLE "mentee" DROP CONSTRAINT "FK_1fd04826f894fd63a0ce080f6e4"` + ) + await queryRunner.query( + `ALTER TABLE "mentee" DROP CONSTRAINT "FK_f671cf2220d1bd0621a1a5e92e7"` + ) + await queryRunner.query( + `ALTER TABLE "monthly_reminders" DROP CONSTRAINT "FK_3c019691242e748da81b48747ff"` + ) + await queryRunner.query( + `ALTER TABLE "monthly-check-in" DROP CONSTRAINT "FK_6de642444085d9599d3f260d566"` + ) + await queryRunner.query(`DROP TABLE "email"`) + await queryRunner.query(`DROP TYPE "public"."email_state_enum"`) + await queryRunner.query(`DROP TABLE "category"`) + await queryRunner.query(`DROP TABLE "mentor"`) + await queryRunner.query(`DROP TYPE "public"."mentor_state_enum"`) + await queryRunner.query(`DROP TABLE "country"`) + await queryRunner.query(`DROP TABLE "profile"`) + await queryRunner.query(`DROP TYPE "public"."profile_type_enum"`) + await queryRunner.query(`DROP TABLE "mentee"`) + await queryRunner.query( + `DROP TYPE "public"."mentee_status_updated_by_enum"` + ) + await queryRunner.query(`DROP TYPE "public"."mentee_state_enum"`) + await queryRunner.query(`DROP TABLE "monthly_reminders"`) + await queryRunner.query( + `DROP TYPE "public"."monthly_reminders_status_enum"` + ) + await queryRunner.query(`DROP TABLE "monthly-check-in"`) + } +} diff --git a/src/routes/admin/admin.route.ts b/src/routes/admin/admin.route.ts index 868fbdc..a82bf2d 100644 --- a/src/routes/admin/admin.route.ts +++ b/src/routes/admin/admin.route.ts @@ -3,6 +3,7 @@ import userRouter from './user/user.route' import mentorRouter from './mentor/mentor.route' import categoryRouter from './category/category.route' import menteeRouter from './mentee/mentee.route' +import reminderRouter from './remainder/remainder.route' const adminRouter = express() diff --git a/src/services/admin/reminder.service.test.ts b/src/services/admin/reminder.service.test.ts new file mode 100644 index 0000000..079894d --- /dev/null +++ b/src/services/admin/reminder.service.test.ts @@ -0,0 +1,210 @@ +// src/services/admin/reminder.service.test.ts +import { MonthlyCheckInReminderService } from './reminder.service' +import { MenteeApplicationStatus, ReminderStatus } from '../../enums' +import { sendEmail } from './email.service' +import { getReminderEmailContent } from '../../utils' + +jest.mock('./email.service') +jest.mock('../../utils') + +describe('MonthlyCheckInReminderService', () => { + let reminderService: MonthlyCheckInReminderService + let mockReminderRepository: any + let mockMenteeRepository: any + + beforeEach(() => { + jest.clearAllMocks() + ;(sendEmail as jest.Mock).mockResolvedValue(undefined) + ;(getReminderEmailContent as jest.Mock).mockReturnValue({ + subject: 'Test Subject', + message: 'Test Message' + }) + + mockReminderRepository = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + save: jest.fn() + } + + mockMenteeRepository = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn() + } + + reminderService = new MonthlyCheckInReminderService( + mockReminderRepository, + mockMenteeRepository + ) + }) + + describe('processReminder', () => { + it('should process new mentees and schedule reminders', async () => { + const mockNewMentee = { + uuid: 'mentee-1', + profile: { + first_name: 'John', + primary_email: 'john@example.com' + }, + state: MenteeApplicationStatus.APPROVED + } + + mockMenteeRepository.getMany.mockResolvedValue([mockNewMentee]) + mockReminderRepository.getMany.mockResolvedValue([]) + mockReminderRepository.save.mockResolvedValue([]) + + const result = await reminderService.processReminder() + + expect(result).toEqual({ + statusCode: 200, + message: 'Processed 0 reminders' + }) + expect(mockReminderRepository.save).toHaveBeenCalled() + }) + + it('should process pending reminders and send emails', async () => { + const mockPendingReminder = { + mentee: { + profile: { + first_name: 'John', + primary_email: 'john@example.com' + } + }, + remindersSent: 0, + status: ReminderStatus.SCHEDULED, + lastSentDate: null, + nextReminderDate: new Date() + } + + mockMenteeRepository.getMany.mockResolvedValue([]) + mockReminderRepository.getMany.mockResolvedValue([mockPendingReminder]) + + let savedReminder: any + mockReminderRepository.save.mockImplementation(async (reminder: any) => { + savedReminder = reminder + return await Promise.resolve(reminder) + }) + + const result = await reminderService.processReminder() + + expect(result).toEqual({ + statusCode: 200, + message: 'Processed 1 reminders' + }) + expect(sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Test Subject', + 'Test Message' + ) + expect(savedReminder.remindersSent).toBe(1) + }) + + it('should mark reminder as completed after max reminders', async () => { + const mockPendingReminder = { + mentee: { + profile: { + first_name: 'John', + primary_email: 'john@example.com' + } + }, + remindersSent: 6, + status: ReminderStatus.SCHEDULED, + lastSentDate: null, + nextReminderDate: new Date() + } + + mockMenteeRepository.getMany.mockResolvedValue([]) + mockReminderRepository.getMany.mockResolvedValue([mockPendingReminder]) + + let savedReminder: any + mockReminderRepository.save.mockImplementation(async (reminder: any) => { + savedReminder = reminder + return await Promise.resolve(reminder) + }) + + await reminderService.processReminder() + + expect(savedReminder.status).toBe(ReminderStatus.COMPLETED) + }) + + it('should handle errors during processing', async () => { + mockMenteeRepository.getMany.mockRejectedValue( + new Error('Database error') + ) + + const result = await reminderService.processReminder() + + expect(result).toEqual({ + statusCode: 500, + message: 'Error processing reminders' + }) + }) + }) + + describe('scheduleNewReminder', () => { + it('should create reminders for new mentees', async () => { + const mockNewMentee = { + uuid: 'mentee-1', + profile: { + first_name: 'John', + primary_email: 'john@example.com' + }, + state: MenteeApplicationStatus.APPROVED + } + + mockMenteeRepository.getMany.mockResolvedValue([mockNewMentee]) + + let savedReminders: any[] + mockReminderRepository.save.mockImplementation( + async (reminders: any[]) => { + savedReminders = reminders + return await Promise.resolve(reminders) + } + ) + + await reminderService.scheduleNewReminder() + + expect(savedReminders![0]).toMatchObject({ + mentee: mockNewMentee, + remindersSent: 0, + status: ReminderStatus.SCHEDULED, + lastSentDate: null + }) + }) + + it('should not create reminders when no new mentees', async () => { + mockMenteeRepository.getMany.mockResolvedValue([]) + + await reminderService.scheduleNewReminder() + + expect(mockReminderRepository.save).not.toHaveBeenCalled() + }) + }) + + describe('calculateNextReminderDate', () => { + it('should calculate next month correctly', async () => { + const currentDate = new Date('2024-01-15') + const result = await reminderService.calculateNextReminderDate( + currentDate + ) + + expect(result.getMonth()).toBe(1) // February + expect(result.getFullYear()).toBe(2024) + }) + + it('should handle year rollover', async () => { + const currentDate = new Date('2024-12-15') + const result = await reminderService.calculateNextReminderDate( + currentDate + ) + + expect(result.getMonth()).toBe(0) // January + expect(result.getFullYear()).toBe(2025) + }) + }) +}) diff --git a/src/services/admin/reminder.service.ts b/src/services/admin/reminder.service.ts new file mode 100644 index 0000000..9ae6cbe --- /dev/null +++ b/src/services/admin/reminder.service.ts @@ -0,0 +1,124 @@ +import { type Repository } from 'typeorm' +import type Mentee from '../../entities/mentee.entity' +import { MonthlyReminder } from '../../entities/reminder.entity' +import { MenteeApplicationStatus, ReminderStatus } from '../../enums' +import { sendEmail } from './email.service' +import { getReminderEmailContent } from '../../utils' + +export class MonthlyCheckInReminderService { + constructor( + private readonly reminderRepository: Repository, + private readonly menteeRepository: Repository + ) {} + + async processReminder(): Promise<{ + statusCode: number + message: string + }> { + try { + await this.scheduleNewReminder() + + const pendingReminders = await this.reminderRepository + .createQueryBuilder('reminder') + .leftJoinAndSelect('reminder.mentee', 'mentee') + .leftJoinAndSelect('mentee.profile', 'profile') + .where('reminder.status = :status', { + status: ReminderStatus.SCHEDULED + }) + .andWhere('reminder.remindersSent < :maxReminders', { + maxReminders: 6 + }) + .andWhere('reminder.nextReminderDate <= :today', { + today: new Date() + }) + .getMany() + + await Promise.all( + pendingReminders.map(async (reminder) => { + const mentee = reminder.mentee + + const emailContent = getReminderEmailContent( + mentee.profile.first_name + ) + + await sendEmail( + mentee.profile.primary_email, + emailContent.subject, + emailContent.message + ) + + reminder.remindersSent += 1 + reminder.lastSentDate = new Date() + reminder.status = + reminder.remindersSent > 6 + ? ReminderStatus.COMPLETED + : ReminderStatus.SCHEDULED + + reminder.nextReminderDate = await this.calculateNextReminderDate( + new Date() + ) + + await this.reminderRepository.save(reminder) + }) + ) + + return { + statusCode: 200, + message: `Processed ${pendingReminders.length} reminders` + } + } catch (error) { + console.error('Error processing reminders:', { + error, + timestamp: new Date().toISOString() + }) + return { + statusCode: 500, + message: 'Error processing reminders' + } + } + } + + public async calculateNextReminderDate(currentDate: Date): Promise { + const nextDate = new Date(currentDate) + nextDate.setMonth(nextDate.getMonth() + 1) + return nextDate + } + + public async scheduleNewReminder(): Promise { + const today = new Date() + + const newMentees = await this.menteeRepository + .createQueryBuilder('mentee') + .leftJoinAndSelect('mentee.profile', 'profile') + .where('mentee.state = :state', { + state: MenteeApplicationStatus.APPROVED + }) + .andWhere((qb) => { + const subQuery = qb + .subQuery() + .select('reminder.mentee.uuid') + .from(MonthlyReminder, 'reminder') + .getQuery() + return 'mentee.uuid NOT IN' + subQuery + }) + .getMany() + + if (newMentees.length > 0) { + const reminders = await Promise.all( + newMentees.map(async (mentee) => { + const reminder = new MonthlyReminder() + reminder.mentee = mentee + reminder.remindersSent = 0 + reminder.status = ReminderStatus.SCHEDULED + reminder.lastSentDate = null + reminder.nextReminderDate = await this.calculateNextReminderDate( + today + ) + return reminder + }) + ) + + await this.reminderRepository.save(reminders) + } + } +} diff --git a/src/services/cron/reminder.cron.ts b/src/services/cron/reminder.cron.ts new file mode 100644 index 0000000..5e47ae6 --- /dev/null +++ b/src/services/cron/reminder.cron.ts @@ -0,0 +1,50 @@ +import cron from 'node-cron' +import { dataSource } from '../../configs/dbConfig' +import { MonthlyCheckInReminderService } from '../admin/reminder.service' +import { MonthlyReminder } from '../../entities/reminder.entity' +import Mentee from '../../entities/mentee.entity' + +export class ReminderCronService { + private readonly reminderService: MonthlyCheckInReminderService + private cronJob!: cron.ScheduledTask + + constructor() { + this.reminderService = new MonthlyCheckInReminderService( + dataSource.getRepository(MonthlyReminder), + dataSource.getRepository(Mentee) + ) + } + + public start(): void { + // Run at 1 AM every day + this.cronJob = cron.schedule( + '*/2 * * * *', + async () => { + try { + console.info('Starting daily reminder processing') + const startTime = Date.now() + + const result = await this.reminderService.processReminder() + + const duration = Date.now() - startTime + console.info(`Completed reminder processing in ${duration}ms`, { + result, + duration + }) + } catch (error) { + console.error('Error in reminder cron job:', error) + } + }, + { + scheduled: true, + timezone: 'UTC' + } + ) + } + + public stop(): void { + if (this.cronJob) { + this.cronJob.stop() + } + } +} diff --git a/src/types.ts b/src/types.ts index 8c39f18..52d9181 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,3 +47,7 @@ export interface MonthlyCheckInResponse { checkInDate: Date mentee: Mentee } + +export interface MessageResponse { + message: string +} diff --git a/src/utils.ts b/src/utils.ts index cb90429..a647052 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -311,3 +311,21 @@ export const formatValidationErrors = ( message: `${issue.path.join('.')} is ${issue.message}` })) } + +export const getReminderEmailContent = ( + name: string +): { subject: string; message: string } => { + return { + subject: 'ScholarX Program Monthly Check-in Reminder', + message: `Dear ${name},

+

This is a friendly reminder that your monthly check-in is due. + Please take a moment to share your progress and updates with your mentor.

+

Things to include in your check-in:

+
    +
  • General updates and feedback
  • +
  • Progress towards your goals
  • +
  • Any challenges or successes you'd like to share
  • +
+ ` + } +} From b2d64b3dd7e586faf1388fe097867b78fa8dee18 Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Tue, 10 Dec 2024 23:20:18 +0530 Subject: [PATCH 5/6] Remove unused reminder router import from admin routes --- src/routes/admin/admin.route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/admin/admin.route.ts b/src/routes/admin/admin.route.ts index a82bf2d..868fbdc 100644 --- a/src/routes/admin/admin.route.ts +++ b/src/routes/admin/admin.route.ts @@ -3,7 +3,6 @@ import userRouter from './user/user.route' import mentorRouter from './mentor/mentor.route' import categoryRouter from './category/category.route' import menteeRouter from './mentee/mentee.route' -import reminderRouter from './remainder/remainder.route' const adminRouter = express() From 003215a38adc431289c5ac0c28687a0764ca0a5e Mon Sep 17 00:00:00 2001 From: mayura-andrew Date: Sun, 12 Jan 2025 21:14:27 +0530 Subject: [PATCH 6/6] Add configurable cron schedule for reminders --- .env.example | 2 + docker-compose.yml | 1 + src/services/admin/reminder.service.test.ts | 210 -------------------- src/services/admin/reminder.service.ts | 208 +++++++++++++------ src/services/cron/reminder.cron.ts | 19 +- 5 files changed, 160 insertions(+), 280 deletions(-) delete mode 100644 src/services/admin/reminder.service.test.ts diff --git a/.env.example b/.env.example index 8eac7fb..fc7ef14 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,5 @@ SMTP_PASSWORD=your_smtp_password LINKEDIN_CLIENT_ID=your_linkedin_client_id LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret LINKEDIN_REDIRECT_URL=http://localhost:${SERVER_PORT}/api/auth/linkedin/callback +# Cron schedule for reminders (default: "0 1 * * *" - 1 AM daily) +REMINDER_CRON_SCHEDULE="0 1 * * *" diff --git a/docker-compose.yml b/docker-compose.yml index f97dd5a..ce569f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - IMG_HOST=${IMG_HOST} - SMTP_MAIL=${SMTP_MAIL} - SMTP_PASSWORD=${SMTP_PASSWORD} + - REMINDER_CRON_SCHEDULE=${REMINDER_CRON_SCHEDULE} command: ["sh", "/app/src/init-db.sh"] db: image: postgres:15 diff --git a/src/services/admin/reminder.service.test.ts b/src/services/admin/reminder.service.test.ts deleted file mode 100644 index 079894d..0000000 --- a/src/services/admin/reminder.service.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -// src/services/admin/reminder.service.test.ts -import { MonthlyCheckInReminderService } from './reminder.service' -import { MenteeApplicationStatus, ReminderStatus } from '../../enums' -import { sendEmail } from './email.service' -import { getReminderEmailContent } from '../../utils' - -jest.mock('./email.service') -jest.mock('../../utils') - -describe('MonthlyCheckInReminderService', () => { - let reminderService: MonthlyCheckInReminderService - let mockReminderRepository: any - let mockMenteeRepository: any - - beforeEach(() => { - jest.clearAllMocks() - ;(sendEmail as jest.Mock).mockResolvedValue(undefined) - ;(getReminderEmailContent as jest.Mock).mockReturnValue({ - subject: 'Test Subject', - message: 'Test Message' - }) - - mockReminderRepository = { - createQueryBuilder: jest.fn().mockReturnThis(), - leftJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn(), - save: jest.fn() - } - - mockMenteeRepository = { - createQueryBuilder: jest.fn().mockReturnThis(), - leftJoinAndSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn() - } - - reminderService = new MonthlyCheckInReminderService( - mockReminderRepository, - mockMenteeRepository - ) - }) - - describe('processReminder', () => { - it('should process new mentees and schedule reminders', async () => { - const mockNewMentee = { - uuid: 'mentee-1', - profile: { - first_name: 'John', - primary_email: 'john@example.com' - }, - state: MenteeApplicationStatus.APPROVED - } - - mockMenteeRepository.getMany.mockResolvedValue([mockNewMentee]) - mockReminderRepository.getMany.mockResolvedValue([]) - mockReminderRepository.save.mockResolvedValue([]) - - const result = await reminderService.processReminder() - - expect(result).toEqual({ - statusCode: 200, - message: 'Processed 0 reminders' - }) - expect(mockReminderRepository.save).toHaveBeenCalled() - }) - - it('should process pending reminders and send emails', async () => { - const mockPendingReminder = { - mentee: { - profile: { - first_name: 'John', - primary_email: 'john@example.com' - } - }, - remindersSent: 0, - status: ReminderStatus.SCHEDULED, - lastSentDate: null, - nextReminderDate: new Date() - } - - mockMenteeRepository.getMany.mockResolvedValue([]) - mockReminderRepository.getMany.mockResolvedValue([mockPendingReminder]) - - let savedReminder: any - mockReminderRepository.save.mockImplementation(async (reminder: any) => { - savedReminder = reminder - return await Promise.resolve(reminder) - }) - - const result = await reminderService.processReminder() - - expect(result).toEqual({ - statusCode: 200, - message: 'Processed 1 reminders' - }) - expect(sendEmail).toHaveBeenCalledWith( - 'john@example.com', - 'Test Subject', - 'Test Message' - ) - expect(savedReminder.remindersSent).toBe(1) - }) - - it('should mark reminder as completed after max reminders', async () => { - const mockPendingReminder = { - mentee: { - profile: { - first_name: 'John', - primary_email: 'john@example.com' - } - }, - remindersSent: 6, - status: ReminderStatus.SCHEDULED, - lastSentDate: null, - nextReminderDate: new Date() - } - - mockMenteeRepository.getMany.mockResolvedValue([]) - mockReminderRepository.getMany.mockResolvedValue([mockPendingReminder]) - - let savedReminder: any - mockReminderRepository.save.mockImplementation(async (reminder: any) => { - savedReminder = reminder - return await Promise.resolve(reminder) - }) - - await reminderService.processReminder() - - expect(savedReminder.status).toBe(ReminderStatus.COMPLETED) - }) - - it('should handle errors during processing', async () => { - mockMenteeRepository.getMany.mockRejectedValue( - new Error('Database error') - ) - - const result = await reminderService.processReminder() - - expect(result).toEqual({ - statusCode: 500, - message: 'Error processing reminders' - }) - }) - }) - - describe('scheduleNewReminder', () => { - it('should create reminders for new mentees', async () => { - const mockNewMentee = { - uuid: 'mentee-1', - profile: { - first_name: 'John', - primary_email: 'john@example.com' - }, - state: MenteeApplicationStatus.APPROVED - } - - mockMenteeRepository.getMany.mockResolvedValue([mockNewMentee]) - - let savedReminders: any[] - mockReminderRepository.save.mockImplementation( - async (reminders: any[]) => { - savedReminders = reminders - return await Promise.resolve(reminders) - } - ) - - await reminderService.scheduleNewReminder() - - expect(savedReminders![0]).toMatchObject({ - mentee: mockNewMentee, - remindersSent: 0, - status: ReminderStatus.SCHEDULED, - lastSentDate: null - }) - }) - - it('should not create reminders when no new mentees', async () => { - mockMenteeRepository.getMany.mockResolvedValue([]) - - await reminderService.scheduleNewReminder() - - expect(mockReminderRepository.save).not.toHaveBeenCalled() - }) - }) - - describe('calculateNextReminderDate', () => { - it('should calculate next month correctly', async () => { - const currentDate = new Date('2024-01-15') - const result = await reminderService.calculateNextReminderDate( - currentDate - ) - - expect(result.getMonth()).toBe(1) // February - expect(result.getFullYear()).toBe(2024) - }) - - it('should handle year rollover', async () => { - const currentDate = new Date('2024-12-15') - const result = await reminderService.calculateNextReminderDate( - currentDate - ) - - expect(result.getMonth()).toBe(0) // January - expect(result.getFullYear()).toBe(2025) - }) - }) -}) diff --git a/src/services/admin/reminder.service.ts b/src/services/admin/reminder.service.ts index 9ae6cbe..778588b 100644 --- a/src/services/admin/reminder.service.ts +++ b/src/services/admin/reminder.service.ts @@ -1,23 +1,119 @@ -import { type Repository } from 'typeorm' +import { MoreThanOrEqual, type Repository } from 'typeorm' import type Mentee from '../../entities/mentee.entity' import { MonthlyReminder } from '../../entities/reminder.entity' import { MenteeApplicationStatus, ReminderStatus } from '../../enums' import { sendEmail } from './email.service' import { getReminderEmailContent } from '../../utils' +import type CheckIn from '../../entities/checkin.entity' export class MonthlyCheckInReminderService { constructor( private readonly reminderRepository: Repository, - private readonly menteeRepository: Repository + private readonly menteeRepository: Repository, + private readonly checkInRepository: Repository ) {} - async processReminder(): Promise<{ + private async processIndividualReminder( + reminder: MonthlyReminder + ): Promise { + try { + const mentee = reminder.mentee + if (!mentee) { + console.error('No mentee found for reminder') + return + } + + const hasSubmitted = await this.hasSubmittedMonthlyCheckIn(mentee) + if (hasSubmitted) { + await this.updateReminderForNextMonth(reminder) + return + } + + try { + await this.sendReminderEmail(mentee) + await this.updateReminderStatus(reminder) + } catch (emailError) { + console.error( + `Error sending reminder email to ${mentee.profile.first_name}`, + { + error: emailError + } + ) + } + } catch (error) { + console.error( + `Error processing reminder for mentee ${reminder.mentee?.uuid}`, + { + error + } + ) + throw error + } + } + + private async hasSubmittedMonthlyCheckIn(mentee: Mentee): Promise { + try { + const currentDate = new Date() + const firstDayOfMonth = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + 1 + ) + + const checkIn = await this.checkInRepository.findOne({ + where: { + mentee: { uuid: mentee.uuid }, + created_at: MoreThanOrEqual(firstDayOfMonth) + } + }) + + return !!checkIn + } catch (error) { + console.error('Error checking monthly submission:', error) + return false + } + } + + private async sendReminderEmail(mentee: Mentee): Promise { + const emailContent = getReminderEmailContent(mentee.profile.first_name) + + await sendEmail( + mentee.profile.primary_email, + emailContent.subject, + emailContent.message + ) + } + + private async updateReminderStatus(reminder: MonthlyReminder): Promise { + reminder.remindersSent += 1 + reminder.lastSentDate = new Date() + reminder.status = + reminder.remindersSent >= 6 + ? ReminderStatus.COMPLETED + : ReminderStatus.SCHEDULED + reminder.nextReminderDate = await this.calculateNextReminderDate( + reminder.lastSentDate + ) + await this.reminderRepository.save(reminder) + } + + private async updateReminderForNextMonth( + reminder: MonthlyReminder + ): Promise { + reminder.nextReminderDate = await this.calculateNextReminderDate(new Date()) + await this.reminderRepository.save(reminder) + } + + public async processReminder(): Promise<{ statusCode: number message: string }> { try { await this.scheduleNewReminder() + const today = new Date() + today.setHours(0, 0, 0, 0) + const pendingReminders = await this.reminderRepository .createQueryBuilder('reminder') .leftJoinAndSelect('reminder.mentee', 'mentee') @@ -28,39 +124,14 @@ export class MonthlyCheckInReminderService { .andWhere('reminder.remindersSent < :maxReminders', { maxReminders: 6 }) - .andWhere('reminder.nextReminderDate <= :today', { - today: new Date() + .andWhere('DATE(reminder.nextReminderDate) <= DATE(:today)', { + today: today.toISOString() }) .getMany() - await Promise.all( - pendingReminders.map(async (reminder) => { - const mentee = reminder.mentee - - const emailContent = getReminderEmailContent( - mentee.profile.first_name - ) - - await sendEmail( - mentee.profile.primary_email, - emailContent.subject, - emailContent.message - ) - - reminder.remindersSent += 1 - reminder.lastSentDate = new Date() - reminder.status = - reminder.remindersSent > 6 - ? ReminderStatus.COMPLETED - : ReminderStatus.SCHEDULED - - reminder.nextReminderDate = await this.calculateNextReminderDate( - new Date() - ) - - await this.reminderRepository.save(reminder) - }) - ) + for (const reminder of pendingReminders) { + await this.processIndividualReminder(reminder) + } return { statusCode: 200, @@ -81,44 +152,49 @@ export class MonthlyCheckInReminderService { public async calculateNextReminderDate(currentDate: Date): Promise { const nextDate = new Date(currentDate) nextDate.setMonth(nextDate.getMonth() + 1) + nextDate.setDate(25) + nextDate.setHours(0, 0, 0, 0) return nextDate } - public async scheduleNewReminder(): Promise { - const today = new Date() - - const newMentees = await this.menteeRepository - .createQueryBuilder('mentee') - .leftJoinAndSelect('mentee.profile', 'profile') - .where('mentee.state = :state', { - state: MenteeApplicationStatus.APPROVED - }) - .andWhere((qb) => { - const subQuery = qb - .subQuery() - .select('reminder.mentee.uuid') - .from(MonthlyReminder, 'reminder') - .getQuery() - return 'mentee.uuid NOT IN' + subQuery - }) - .getMany() - - if (newMentees.length > 0) { - const reminders = await Promise.all( - newMentees.map(async (mentee) => { - const reminder = new MonthlyReminder() - reminder.mentee = mentee - reminder.remindersSent = 0 - reminder.status = ReminderStatus.SCHEDULED - reminder.lastSentDate = null - reminder.nextReminderDate = await this.calculateNextReminderDate( - today - ) - return reminder + private async scheduleNewReminder(): Promise { + try { + const newMentees = await this.menteeRepository + .createQueryBuilder('mentee') + .leftJoinAndSelect('mentee.profile', 'profile') + .where('mentee.state = :state', { + state: MenteeApplicationStatus.APPROVED }) - ) + .andWhere((qb) => { + const subQuery = qb + .subQuery() + .select('reminder.mentee.uuid') + .from(MonthlyReminder, 'reminder') + .getQuery() + return 'mentee.uuid NOT IN' + subQuery + }) + .getMany() + + if (newMentees.length > 0) { + const reminders = await Promise.all( + newMentees.map(async (mentee) => { + const reminder = new MonthlyReminder() + reminder.mentee = mentee + reminder.remindersSent = 0 + reminder.status = ReminderStatus.SCHEDULED + reminder.lastSentDate = null + reminder.nextReminderDate = await this.calculateNextReminderDate( + new Date() + ) + return reminder + }) + ) - await this.reminderRepository.save(reminders) + await this.reminderRepository.save(reminders) + } + } catch (error) { + console.error('Error scheduling new reminders:', error) + throw error } } } diff --git a/src/services/cron/reminder.cron.ts b/src/services/cron/reminder.cron.ts index 5e47ae6..68690e1 100644 --- a/src/services/cron/reminder.cron.ts +++ b/src/services/cron/reminder.cron.ts @@ -3,25 +3,36 @@ import { dataSource } from '../../configs/dbConfig' import { MonthlyCheckInReminderService } from '../admin/reminder.service' import { MonthlyReminder } from '../../entities/reminder.entity' import Mentee from '../../entities/mentee.entity' +import CheckIn from '../../entities/checkin.entity' export class ReminderCronService { private readonly reminderService: MonthlyCheckInReminderService private cronJob!: cron.ScheduledTask + private readonly cronSchedule: string constructor() { this.reminderService = new MonthlyCheckInReminderService( dataSource.getRepository(MonthlyReminder), - dataSource.getRepository(Mentee) + dataSource.getRepository(Mentee), + dataSource.getRepository(CheckIn) ) + this.cronSchedule = process.env.REMINDER_CRON_SCHEDULE ?? '0 1 * * *' // Default to 1 AM every day + + if (!cron.validate(this.cronSchedule)) { + throw new Error(`Invalid cron schedule: ${this.cronSchedule}`) + } } public start(): void { - // Run at 1 AM every day this.cronJob = cron.schedule( - '*/2 * * * *', + this.cronSchedule, async () => { try { - console.info('Starting daily reminder processing') + console.info('Starting daily reminder processing', { + cronSchedule: this.cronSchedule, + timeStamp: new Date().toISOString() + }) + const startTime = Date.now() const result = await this.reminderService.processReminder()