diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..8e950a23 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Describe your changes + +Briefly describe the changes you made and their purpose. + +## Issue number + +Mention the issue number(s) this PR addresses (e.g., #123). + +## Please ensure all items are checked off before requesting a review: + +- [ ] I deployed the code locally. +- [ ] I have performed a self-review of my code. +- [ ] I have included the issue # in the PR. +- [ ] I have labelled the PR correctly. +- [ ] The issue I am working on is assigned to me. +- [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application). +- [ ] I made sure font sizes, color choices etc are all referenced from the theme. +- [ ] My PR is granular and targeted to one specific feature. +- [ ] I took a screenshot or a video and attached to this PR if there is a UI change. diff --git a/README.md b/README.md index a7bf2ff9..2906a297 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,87 @@ This is a work-in-progress application. The source code is available under GNU A - [Node.js](https://nodejs.org/en) - [PostgreSQL](https://postgresql.org) + +## Installation + +1. Make sure Docker is installed to your machine where the server will run. +2. Make sure git is installed to your machine Git. +3. Make sure nginx is installed. + +4. Clone GitHub Repository + +``` +cd ~ +git clone https://github.com/bluewave-labs/bluewave-onboarding.git +cd bluewave-onboarding +``` + +5. Configure Nginx + +Open the Nginx configuration file: + +``sudo nano /etc/nginx/sites-available/onboarding-demo`` + +Add the following configuration. Change YOUR_DOMAIN_NAME with your domain name: + +```server { + listen 80; + server_name YOUR_DOMAIN_NAME; + return 301 https://$host$request_uri; + } + +server { + listen 443 ssl; + server_name YOUR_DOMAIN_NAME; + ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN_NAME/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN_NAME/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://localhost:4173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + + +6. Create a symbolic link to enable the configuration: + +``sudo ln -s /etc/nginx/sites-available/onboarding-demo /etc/nginx/sites-enabled/`` + +7. Install Certbot and its Nginx plugin: + +``sudo apt install certbot python3-certbot-nginx`` + +8. Obtain SSL Certificate. Run Certbot to obtain a certificate for your domain: + +``sudo certbot --nginx`` + +9. Verify the Nginx configuration: + +``sudo nginx -t`` + +10. Restart Nginx to apply the changes: + +``sudo systemctl restart nginx`` + +11. Start the project + +``cd ~/bluewave-onboarding +docker compose up -d`` + ## Contributing Here's how you can contribute to the Onboarding product. diff --git a/backend/.env b/backend/.env index 3cdfaaac..b1057599 100644 --- a/backend/.env +++ b/backend/.env @@ -19,6 +19,7 @@ TEST_DB_NAME=onboarding_db_test TEST_DB_HOST=localhost TEST_DB_PORT=5432 +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200, 192.168.65/1-255 # Allowed IP addresses for the API separated by comma diff --git a/backend/.env.production b/backend/.env.production index ec404c01..7de5ae9b 100644 --- a/backend/.env.production +++ b/backend/.env.production @@ -10,6 +10,7 @@ PROD_DB_PORT=5432 # JWT Secret Key JWT_SECRET=your_prod_jwt_secret_key_here +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200 # Allowed IP addresses for the API separated by comma diff --git a/backend/.env.test b/backend/.env.test index 09be3c11..7507a9c6 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -14,6 +14,7 @@ POSTGRES_DB=onboarding_db_test # JWT Secret Key JWT_SECRET=your_test_jwt_secret_key_here +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200, 192.168.65/1-255 # Allowed IP addresses for the API separated by comma diff --git a/backend/migrations/20241219181037-add-index-guidelog.js b/backend/migrations/20241219181037-add-index-guidelog.js new file mode 100644 index 00000000..c4b45874 --- /dev/null +++ b/backend/migrations/20241219181037-add-index-guidelog.js @@ -0,0 +1,28 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + await queryInterface.addIndex("guide_logs", ["showingTime"], { + name: "idx_guide_logs_showingTime", + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex("guide_logs", ["showingTime"], { + name: "idx_guide_logs_showingTime", + }); + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + }, +}; diff --git a/backend/src/controllers/guide.controller.js b/backend/src/controllers/guide.controller.js index e1f432cf..965347e9 100644 --- a/backend/src/controllers/guide.controller.js +++ b/backend/src/controllers/guide.controller.js @@ -2,7 +2,7 @@ const bannerService = require("../service/banner.service"); const guidelogService = require("../service/guidelog.service"); const helperLinkService = require("../service/helperLink.service"); const popupService = require("../service/popup.service"); - +const { internalServerError } = require("../utils/errors.helper"); class GuideController { async getGuidesByUrl(req, res) { try { diff --git a/backend/src/controllers/statistics.controller.js b/backend/src/controllers/statistics.controller.js new file mode 100644 index 00000000..1696694f --- /dev/null +++ b/backend/src/controllers/statistics.controller.js @@ -0,0 +1,23 @@ +const statisticsService = require("../service/statistics.service"); +const { internalServerError } = require("../utils/errors.helper"); + +class StatisticsController { + async getStatistics(req, res) { + try { + const userId = req.user.id; + const statistics = await statisticsService.generateStatistics({ + userId: userId.toString(), + }); + res.status(200).json(statistics); + } catch (e) { + console.log(e) + const { statusCode, payload } = internalServerError( + "GET_STATISTICS_ERROR", + e.message + ); + res.status(statusCode).json(payload); + } + } +} + +module.exports = new StatisticsController(); diff --git a/backend/src/models/GuideLog.js b/backend/src/models/GuideLog.js index c007fddc..d4841ef6 100644 --- a/backend/src/models/GuideLog.js +++ b/backend/src/models/GuideLog.js @@ -1,58 +1,64 @@ -const { GuideType } = require('../utils/guidelog.helper'); +const { GuideType } = require("../utils/guidelog.helper"); module.exports = (sequelize, DataTypes) => { - const GuideLog = sequelize.define('GuideLog', { - guideType: { - type: DataTypes.INTEGER, - allowNull: false, - validate: { - isIn: { - args: [ - Object.values(GuideType), - ], - msg: 'guideType must be a valid value.', - }, - }, + const GuideLog = sequelize.define( + "GuideLog", + { + guideType: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + isIn: { + args: [Object.values(GuideType)], + msg: "guideType must be a valid value.", + }, }, - guideId: { - type: DataTypes.INTEGER, - allowNull: false, + }, + guideId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + }, + showingTime: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + completed: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + timestamps: false, + tableName: "guide_logs", + indexes: [ + { + name: "idx_guide_logs_userId", + fields: ["userId"], }, - userId: { - type: DataTypes.STRING, - allowNull: false, + { + name: "idx_guide_logs_guideId", + fields: ["guideId"], }, - showingTime: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW, + { + name: "idx_guide_logs_guideType", + fields: ["guideType"], }, - completed: { - type: DataTypes.BOOLEAN, - defaultValue: false, + { + name: "idx_guide_logs_userId_guideId_guideType", + fields: ["userId", "guideId", "guideType"], + unique: false, }, - }, { - timestamps: false, - tableName: 'guide_logs', - indexes: [ - { - name: 'idx_guide_logs_userId', - fields: ['userId'], - }, - { - name: 'idx_guide_logs_guideId', - fields: ['guideId'], - }, - { - name: 'idx_guide_logs_guideType', - fields: ['guideType'], - }, - { - name: 'idx_guide_logs_userId_guideId_guideType', - fields: ['userId', 'guideId', 'guideType'], - unique: false, - }, - ], - }); + { + name: "idx_guide_logs_showingTime", + fields: ["showingTime"], + }, + ], + } + ); - return GuideLog; + return GuideLog; }; diff --git a/backend/src/routes/statistics.routes.js b/backend/src/routes/statistics.routes.js new file mode 100644 index 00000000..7022c551 --- /dev/null +++ b/backend/src/routes/statistics.routes.js @@ -0,0 +1,9 @@ +const express = require("express"); +const statisticsController = require("../controllers/statistics.controller"); +const authenticateJWT = require("../middleware/auth.middleware"); + +const router = express.Router(); +router.use(authenticateJWT) +router.get("/", statisticsController.getStatistics); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index e85dfe51..356e709b 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -23,6 +23,7 @@ const tourRoutes = require("./routes/tour.routes"); const linkRoutes = require("./routes/link.routes"); const helperLinkRoutes = require("./routes/helperLink.routes"); const guideRoutes = require("./routes/guide.routes"); +const statisticsRoutes = require("./routes/statistics.routes"); const app = express(); @@ -30,7 +31,9 @@ app.use(cors()); app.use(helmet()); app.use(bodyParser.json({ limit: MAX_FILE_SIZE })); app.use(jsonErrorMiddleware); -app.use(ipFilter); +if (process.env.ENABLE_IP_CHECK === 'true') { + app.use(ipFilter); +} // app.use(fileSizeValidator); const { sequelize } = require("./models"); @@ -57,6 +60,7 @@ app.use("/api/hint", hint); app.use("/api/tour", tourRoutes); app.use("/api/link", linkRoutes); app.use("/api/helper-link", helperLinkRoutes); +app.use("/api/statistics", statisticsRoutes); app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/service/statistics.service.js b/backend/src/service/statistics.service.js new file mode 100644 index 00000000..d008e6d4 --- /dev/null +++ b/backend/src/service/statistics.service.js @@ -0,0 +1,58 @@ +const db = require("../models"); +const { GuideType } = require("../utils/guidelog.helper"); +const GuideLog = db.GuideLog; + +class StatisticsService { + async generateStatistics({ userId }) { + try { + const now = new Date(); + const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 2, 1); + const views = []; + for (const [guideName, guideType] of Object.entries(GuideType)) { + const logs = await GuideLog.findAll({ + where: { + guideType: Number(guideType), + userId, + showingTime: { + [db.Sequelize.Op.gte]: twoMonthsAgo, + }, + }, + }); + const { thisMonthViews, lastMonthViews } = logs.reduce( + (acc, log) => { + if (log.guideType !== guideType) { + return acc; + } + if (log.showingTime >= thisMonth) { + acc.thisMonthViews += 1; + } else if (log.showingTime >= lastMonth) { + acc.lastMonthViews += 1; + } + return acc; + }, + { thisMonthViews: 0, lastMonthViews: 0 } + ); + const percentageDifference = + lastMonthViews === 0 + ? 0 + : Math.round( + ((thisMonthViews - lastMonthViews) / lastMonthViews) * 100 + ); + const result = { + views: thisMonthViews, + change: percentageDifference, + guideType: guideName.toLowerCase(), + }; + views.push(result); + } + return views.sort((a, b) => b.views - a.views); + } catch (error) { + console.log(error); + throw new Error(`Failed to generate statistics: ${error.message}`); + } + } +} + +module.exports = new StatisticsService(); diff --git a/backend/src/test/e2e/statistics.test.mjs b/backend/src/test/e2e/statistics.test.mjs new file mode 100644 index 00000000..3b968692 --- /dev/null +++ b/backend/src/test/e2e/statistics.test.mjs @@ -0,0 +1,90 @@ +import { expect } from "chai"; +import { after, afterEach, before, beforeEach, describe } from "mocha"; +import waitOn from "wait-on"; +import db from "../../models/index.js"; +import app from "../../server.js"; +import mocks from "../mocks/guidelog.mock.js"; +import { UserBuilder, validList } from "../mocks/user.mock.js"; +import chai from "./index.mjs"; + +const user = UserBuilder.user; +const dbReadyOptions = { + resources: ["tcp:localhost:5432"], + delay: 1000, + timeout: 30000, + interval: 1000, +}; + +const guideLogList = mocks.guideLogList; + +const setupTestDatabase = () => { + before(async () => { + db.sequelize.connectionManager.initPools(); + }); + + after(async () => { + try { + const conn = await db.sequelize.connectionManager.getConnection(); + db.sequelize.connectionManager.releaseConnection(conn); + } catch (error) { + console.error("Failed to release database connection:", error); + throw error; + } + }); +}; + +const populateGuideLogs = async (token) => { + for (const guideLog of guideLogList) { + await chai.request + .execute(app) + .post("/api/guide_log/add_guide_log") + .set("Authorization", `Bearer ${token}`) + .send(guideLog); + } +} + +describe("E2e tests statistics", () => { + describe("GET /api/statistics", () => { + setupTestDatabase(); + let token; + + beforeEach(async () => { + process.env.NODE_ENV = "test"; + try { + await waitOn(dbReadyOptions); + } catch (err) { + console.error("Database not ready in time:", err); + throw err; + } + const login = await chai.request + .execute(app) + .post("/api/auth/register") + .send(user().build()); + token = login.body.token; + }); + afterEach(async () => { + await db.sequelize.sync({ force: true, match: /_test$/ }); + }); + it("should return 401 if token is not passed", async () => { + const response = await chai.request.execute(app).get("/api/statistics"); + expect(response).to.have.status(401); + expect(response.body).to.be.deep.equal({ error: "No token provided" }); + }) + it("should return 200 if token is passed", async () => { + populateGuideLogs(token); + const response = await chai.request + .execute(app) + .get("/api/statistics") + .set("Authorization", `Bearer ${token}`); + expect(response).to.have.status(200); + expect(response.body).not.to.deep.equal(guideLogList); + expect(response.body).to.be.an("array"); + expect(response.body).to.have.lengthOf(6); + response.body.forEach((statistic) => { + expect(statistic).to.have.property("views"); + expect(statistic).to.have.property("change"); + expect(statistic).to.have.property("guideType"); + }) + }) + }); +}); diff --git a/backend/src/test/mocks/guidelog.mock.js b/backend/src/test/mocks/guidelog.mock.js index 2de5ac73..43d561d0 100644 --- a/backend/src/test/mocks/guidelog.mock.js +++ b/backend/src/test/mocks/guidelog.mock.js @@ -1,16 +1,18 @@ +const { GuideType } = require("../../utils/guidelog.helper"); + class GuideLogBuilder { - constructor(id) { + constructor(id, guideType) { this.guideLog = { - guideType: 3, + guideType, guideId: id, - userId: '1', + userId: "1", showingTime: new Date("2024-11-29T00:00:00.000Z"), completed: false, }; } - static guideLog(id = 1) { - return new GuideLogBuilder(id); + static guideLog(id = 1, guideType = 1) { + return new GuideLogBuilder(id, guideType); } invalidGuideType() { @@ -44,7 +46,20 @@ class GuideLogBuilder { } const guideLogList = Array.from({ length: 10 }, (_, i) => { - return GuideLogBuilder.guideLog(i + 1).build(); + const values = Object.values(GuideType); + let index = 0; + if (i % 2 === 0 && i % 3 === 0) { + index = 1; + } else if (i % 3 === 0) { + index = 2; + } else if (i % 2 === 0 && i % 5 === 0) { + index = 3; + } else if (i % 2 === 0) { + index = 4; + } else if (i % 5 === 0) { + index = 5; + } + return GuideLogBuilder.guideLog(i + 1, values[index]).build(); }).map((guideLog, i) => (i % 2 === 0 ? guideLog : { ...guideLog, userId: 2 })); module.exports = { GuideLogBuilder, guideLogList }; diff --git a/backend/src/test/unit/controllers/statistics.test.js b/backend/src/test/unit/controllers/statistics.test.js new file mode 100644 index 00000000..74ffc6e1 --- /dev/null +++ b/backend/src/test/unit/controllers/statistics.test.js @@ -0,0 +1,43 @@ +const { describe, it, afterEach } = require("mocha"); +const { expect } = require("chai"); +const sinon = require("sinon"); +const service = require("../../../service/statistics.service.js"); +const { guideLogList } = require("../../mocks/guidelog.mock.js"); +const controller = require("../../../controllers/statistics.controller.js"); + +describe("Test Statistics controller", () => { + const serviceMock = {}; + const req = {}; + const res = {}; + beforeEach(() => { + res.status = sinon.stub().returns(res); + res.json = sinon.stub().returns(res); + }); + afterEach(sinon.restore); + describe("getStatistics", () => { + req.user = { id: 1 }; + it("should return status 200 and the statistics to all guides", async () => { + serviceMock.generateStatistics = sinon + .stub(service, "generateStatistics") + .resolves(guideLogList); + await controller.getStatistics(req, res); + const status = res.status.firstCall.args[0]; + const json = res.json.firstCall.args[0]; + expect(status).to.be.equal(200); + expect(json).to.be.equal(guideLogList); + }); + it("should return status 500 if something goes wrong", async () => { + serviceMock.generateStatistics = sinon + .stub(service, "generateStatistics") + .rejects(`Failed to generate statistics:`); + await controller.getStatistics(req, res); + const status = res.status.firstCall.args[0]; + const json = res.json.firstCall.args[0]; + expect(status).to.be.equal(500); + expect(json).to.be.deep.equal({ + error: "Internal Server Error", + errorCode: "GET_STATISTICS_ERROR", + }); + }); + }); +}); diff --git a/backend/src/test/unit/services/statistics.test.js b/backend/src/test/unit/services/statistics.test.js new file mode 100644 index 00000000..8ca765aa --- /dev/null +++ b/backend/src/test/unit/services/statistics.test.js @@ -0,0 +1,29 @@ +const { describe, it, beforeEach, afterEach } = require("mocha"); +const sinon = require("sinon"); +const { expect } = require("chai"); +const db = require("../../../models/index.js"); +const mocks = require("../../mocks/guidelog.mock.js"); +const statisticsService = require("../../../service/statistics.service.js"); + +const GuideLog = db.GuideLog; + +describe("Test statistics service", () => { + const GuideLogMock = {}; + afterEach(sinon.restore); + it("should return statistics", async () => { + const guideLogs = mocks.guideLogList; + GuideLogMock.findAll = sinon.stub(GuideLog, "findAll").resolves(guideLogs); + const statistics = await statisticsService.generateStatistics({ + userId: 1, + }); + + expect(GuideLogMock.findAll.called).to.equal(true); + expect(statistics).to.be.an("array"); + expect(statistics).to.have.lengthOf(6); + statistics.forEach((statistic) => { + expect(statistic).to.have.property("guideType"); + expect(statistic).to.have.property("views"); + expect(statistic).to.have.property("change"); + }); + }); +}); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 90f92f9f..8d42b4b9 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,8 +5,8 @@