diff --git a/package-lock.json b/package-lock.json index e1301262..6a519a63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/jsonwebtoken": "^9.0.6", "@types/mailgun-js": "^0.22.18", "@types/morgan": "^1.9.9", + "@types/node-cron": "^3.0.11", "@types/node-fetch": "^2.6.11", "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.16", @@ -1882,6 +1883,12 @@ "undici-types": "~5.26.4" } }, + "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 + }, "node_modules/@types/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", diff --git a/src/__test__/subscription.test.ts b/src/__test__/subscription.test.ts new file mode 100644 index 00000000..dfa18493 --- /dev/null +++ b/src/__test__/subscription.test.ts @@ -0,0 +1,97 @@ +import request from 'supertest'; +import app from '../app'; +import { afterAllHook, beforeAllHook } from './testSetup'; +import dbConnection from '../database'; +import Subscription from '../database/models/Subscribe'; + +const subscribeRepository = dbConnection.getRepository(Subscription); + +beforeAll(async () => { + await beforeAllHook(); +}); + +afterAll(async () => { + await afterAllHook(); +}); + +describe('POST /api/v1/subscribe', () => { + beforeEach(async () => { + // Clear the table before each test + await subscribeRepository.clear(); + }); + + it('should subscribe successfully with a valid email', async () => { + const response = await request(app) + .post('/api/v1/subscribe') + .send({ email: 'test@example.com' }); + + expect(response.status).toBe(201); + expect(response.body.message).toBe('Subscribed successfully'); + expect(response.body.subscription.email).toBe('test@example.com'); + + const subscription = await subscribeRepository.findOne({ + where: { email: 'test@example.com' }, + }); + expect(subscription).toBeDefined(); + }); + + it('should return 400 if the email is already subscribed', async () => { + const subscription = new Subscription(); + subscription.email = 'test@example.com'; + await subscribeRepository.save(subscription); + + const response = await request(app) + .post('/api/v1/subscribe') + .send({ email: 'test@example.com' }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Email is already subscribed'); + }); + + it('should return 400 for an invalid email format', async () => { + const response = await request(app) + .post('/api/v1/subscribe') + .send({ email: 'invalid-email' }); + + expect(response.status).toBe(400); + expect(response.body.errors[0].msg).toBe('Email is not valid'); + }); +}); + +describe('DELETE /api/v1/subscribe/delete/:id', () => { + beforeEach(async () => { + // Clear the table before each test + await subscribeRepository.clear(); + }); + + it('should remove a subscription successfully', async () => { + const subscription = new Subscription(); + subscription.email = 'test@example.com'; + await subscribeRepository.save(subscription); + + const response = await request(app) + .delete(`/api/v1/subscribe/delete/${subscription.id}`) + .send(); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Subscription removed successfully'); + }); + + it('should return 404 if the subscription does not exist', async () => { + const response = await request(app) + .delete('/api/v1/subscribe/delete/450') + .send(); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Subscription not found'); + }); + + it('should return 400 for invalid ID', async () => { + const response = await request(app) + .delete('/api/v1/subscribe/delete/noid') + .send(); + + expect(response.status).toBe(400); + expect(response.body.message).toBeUndefined(); + }); +}); diff --git a/src/controller/subscribeController.ts b/src/controller/subscribeController.ts new file mode 100644 index 00000000..88f6fb06 --- /dev/null +++ b/src/controller/subscribeController.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import errorHandler from '../middlewares/errorHandler'; +import dbConnection from '../database'; +import Subscription from '../database/models/Subscribe'; +import { check, validationResult } from 'express-validator'; + +const subscribeRepository = dbConnection.getRepository(Subscription); +const userEmailRules = [ + check('email').isEmail().normalizeEmail().withMessage('Email is not valid'), +]; +export const subscribe = [ + ...userEmailRules, + errorHandler(async (req: Request, res: Response) => { + const { email } = req.body; + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const alreadSubscribed = await subscribeRepository.findOneBy({ + email: req.body.email, + }); + if (alreadSubscribed) { + return res.status(400).json({ message: 'Email is already subscribed' }); + } + + const subscription = new Subscription(); + subscription.email = email; + + await subscribeRepository.save(subscription); + res.status(201).json({ message: 'Subscribed successfully', subscription }); + }), +]; + +const userIdRules = [ + check('id').isInt({ min: 1 }).withMessage(' ID is required'), +]; + +export const removeSubscriber = [ + ...userIdRules, + errorHandler(async (req: Request, res: Response) => { + const id: number = parseInt(req.params.id); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + try { + const subscription = await subscribeRepository.findOne({ + where: { id }, + }); + + if (!subscription) { + return res.status(404).json({ message: 'Subscription not found' }); + } + + await subscribeRepository.remove(subscription); + res.status(200).json({ message: 'Subscription removed successfully' }); + } catch (error) { + res.status(500).json({ message: 'Error removing subscription', error }); + } + }), +]; diff --git a/src/database/models/Subscribe.ts b/src/database/models/Subscribe.ts new file mode 100644 index 00000000..7bc4bbfd --- /dev/null +++ b/src/database/models/Subscribe.ts @@ -0,0 +1,12 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { IsEmail } from 'class-validator'; + +@Entity() +export default class Subscription { + @PrimaryGeneratedColumn() + id: number; + + @Column() + @IsEmail() + email: string; +} diff --git a/src/emails/index.ts b/src/emails/index.ts index 8f88ef6f..5efc3eb5 100644 --- a/src/emails/index.ts +++ b/src/emails/index.ts @@ -1,10 +1,10 @@ import axios from 'axios'; import handlebars from 'handlebars'; import fs from 'fs'; -type EmailType = 'confirm' | 'reset'; +type EmailType = 'confirm' | 'reset' | 'subscription'; type Data = { - name: string; - link: string; + name?: string; + link?: string; }; /** * Sends an email of the specified type to the recipient using the provided data. @@ -15,7 +15,7 @@ type Data = { * @returns A Promise that resolves to the response from the email service. * @throws An error if there is an issue sending the email. */ -async function sendEmail(emailType: EmailType, recipient: string, data: Data) { +async function sendEmail(emailType: EmailType, recipient: string, data?: Data) { const templatePath = `./src/emails/templates/${emailType}.html`; try { // Read the Handlebars template file diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 6b64745f..e25f75be 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -17,6 +17,7 @@ import { } from '../controller/changestatusController'; import { checkRole } from '../middlewares/authorize'; import { IsLoggedIn } from '../middlewares/isLoggedIn'; +import { subscribe, removeSubscriber } from '../controller/subscribeController'; const userRouter = Router(); userRouter.post('/register', registerUser); @@ -39,7 +40,9 @@ userRouter.put( deactivateAccount ); userRouter.post('/recover', recoverPassword); -userRouter.put('/recover/confirm', updateNewPassword) +userRouter.put('/recover/confirm', updateNewPassword); -userRouter.put('/updateProfile/:id',updateProfile); +userRouter.put('/updateProfile/:id', updateProfile); +userRouter.post('/subscribe', subscribe); +userRouter.delete('/subscribe/delete/:id', removeSubscriber); export default userRouter;