diff --git a/.gitignore b/.gitignore index 6fd37b3..96c43ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ node_modules/ -backend/.env +backend/.env.test +backend/.env.development +backend/.env.production diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..07fb6db --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +.idea +node_modules +npm-debug.log +.env.* diff --git a/backend/.env.template b/backend/.env.template index 8fe39b7..399c7cf 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,20 +1,9 @@ -DB_TEST_USER=dev_user -DB_TEST_PASSWORD=dev_password -DB_TEST_NAME=dev_db -DB_TEST_HOST=127.0.0.1 -DB_TEST_PORT=5432 -DB_TEST_DIALECT=postgres +DB_USER=dev_user +DB_PASSWORD=dev_password +DB_NAME=dev_db +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DIALECT=postgres -DB_DEV_USER=dev_user -DB_DEV_PASSWORD=dev_password -DB_DEV_NAME=dev_db -DB_DEV_HOST=127.0.0.1 -DB_DEV_PORT=5432 -DB_DEV_DIALECT=postgres - -DB_PROD_USER=prod_user -DB_PROD_PASSWORD=prod_password -DB_PROD_NAME=prod_db -DB_PROD_HOST=prod-db-server.com -DB_PROD_PORT=5432 -DB_PROD_DIALECT=postgres +NODE_LOCAL_PORT=8124 +NODE_DOCKER_PORT=8080 \ No newline at end of file diff --git a/backend/.sequelizerc b/backend/.sequelizerc new file mode 100644 index 0000000..3923eb6 --- /dev/null +++ b/backend/.sequelizerc @@ -0,0 +1,9 @@ +// .sequelizerc +const path = require('path'); + +module.exports = { + 'config': path.resolve('config/config.js'), + 'models-path': path.resolve('models'), + 'seeders-path': path.resolve('seeders'), + 'migrations-path': path.resolve('migrations') +}; diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ccc7199 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,42 @@ +ARG NODE_VERSION=18.0.0 +FROM node:${NODE_VERSION}-alpine AS base +WORKDIR /user/src/app +EXPOSE 8080 + +FROM base as production +ENV NODE_ENV=production +COPY .env.production .env +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/root/.npm \ + npm ci --omit=dev +USER node +COPY . . +EXPOSE 8080 +CMD ["node", "server.js"] + +FROM base as development +ENV NODE_ENV=development +COPY .env.development .env +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/root/.npm \ + npm ci --include=dev +USER node +COPY . . +CMD ["npm", "run", "dev"] + + +#FROM node:20-alpine as build +#WORKDIR /app +#COPY package*.json ./ +#RUN npm install --omit dev +#COPY . . +# +#FROM node:20-alpine +#WORKDIR /app +#COPY --from=build /app /app +#ENV NODE_ENV=production +#COPY .env.production .env +#EXPOSE 8080 +#CMD ["node", "server.js"] diff --git a/backend/app.js b/backend/app.js index 642c87d..4173a7a 100644 --- a/backend/app.js +++ b/backend/app.js @@ -20,44 +20,11 @@ app.use(express.urlencoded({ extended: true })); // initial(); // }); -// simple route -app.get("/", (req, res) => { - res.json({ message: "Welcome to bullpen application." }); -}); - // routes +require('./routes/info.routes')(app); require('./routes/auth.routes')(app); require('./routes/user.routes')(app); require('./routes/pitchType.routes')(app); require('./routes/bullpenSession.routes')(app); -// function initial() { -// Role.bulkCreate([ -// { name: 'user' }, -// { name: 'administrator' }, -// ]); -// User.bulkCreate([ -// { firstName: 'Nolan', lastName: 'Ryan', dateOfBirth: new Date(1947, 1, 31), email: 'ryan.nolan@bullpen.com', password: bcrypt.hashSync('nolan', 8) }, -// { firstName: 'Sandy', lastName: 'Koufax', dateOfBirth: new Date(1935, 12, 30), email: 'sandy.koufax@bullpen.com', password: bcrypt.hashSync('sandy', 8) }, -// { firstName: 'Pedro', lastName: 'Martinez', dateOfBirth: new Date(1971, 10, 25), email: 'pedro.martinez@bullpen.com', password: bcrypt.hashSync('pedro', 8) }, -// { firstName: 'randy', lastName: 'johnson', dateOfBirth: new Date(1963, 9, 10), email: 'randy.johnson@bullpen.com', password: bcrypt.hashSync('randy', 8) } -// ]); -// -// User.findAll().then(users => { -// users.forEach(user => { -// user.setRoles([1]); -// }); -// }); -// -// PitchType.bulkCreate([ -// { name: 'Fastball', abbreviation: 'FB' }, -// { name: 'Curveball', abbreviation: 'CB' }, -// { name: 'Slider', abbreviation: 'SL' }, -// { name: 'Changeup', abbreviation: 'CH' }, -// { name: 'Cutter', abbreviation: 'CUT' }, -// { name: 'Sweeper', abbreviation: 'SW' }, -// { name: 'Slurve', abbreviation: 'SLV' }, -// ]); -// } - module.exports = app; diff --git a/backend/config/config.js b/backend/config/config.js index de5f27d..ff3f264 100644 --- a/backend/config/config.js +++ b/backend/config/config.js @@ -1,31 +1,23 @@ -require('dotenv').config(); +const dotenv = require('dotenv'); +const path = require('path'); + +const envFile = { + production: '.env.production', + test: '.env.test' +}[process.env.NODE_ENV || ''] || '.env'; + +dotenv.config({ + path: path.resolve(process.cwd(), envFile) +}); module.exports = { - test: { - username: process.env.DB_TEST_USER, - password: process.env.DB_TEST_PASSWORD, - database: process.env.DB_TEST_NAME, - host: process.env.DB_TEST_HOST, - port: process.env.DB_TEST_PORT, - dialect: process.env.DB_TEST_DIALECT, + [process.env.NODE_ENV || 'development']: { + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: process.env.DB_DIALECT, logging: false, - }, - development: { - username: process.env.DB_DEV_USER, - password: process.env.DB_DEV_PASSWORD, - database: process.env.DB_DEV_NAME, - host: process.env.DB_DEV_HOST, - port: process.env.DB_DEV_PORT, - dialect: process.env.DB_DEV_DIALECT, - logging: false, - }, - production: { - username: process.env.DB_PROD_USER, - password: process.env.DB_PROD_PASSWORD, - database: process.env.DB_PROD_NAME, - host: process.env.DB_PROD_HOST, - port: process.env.DB_PROD_PORT, - dialect: process.env.DB_PROD_DIALECT, - logging: false, - }, + } }; diff --git a/backend/controllers/bullpenSession.controller.js b/backend/controllers/bullpenSession.controller.js index 9401132..471e066 100644 --- a/backend/controllers/bullpenSession.controller.js +++ b/backend/controllers/bullpenSession.controller.js @@ -1,9 +1,24 @@ const db = require("../models/index"); const BullpenSession = db.BullpenSession; const Pitch = db.Pitch; +const Op = db.Sequelize.Op; + +const getPagination = (page, size) => { + const limit = size ? +size : 3; + const offset = page ? page * limit : 0; + + return { limit, offset }; +}; + +const getPageData = (data, totalCount, page, limit) => { + const currentPage = page ? +page : 0; + const totalPages = Math.ceil(totalCount / limit); + + return { totalCount, data, totalPages, currentPage }; +}; exports.insert = (req, res) => { - BullpenSession.create(req.body.bullpen, { include: [{ + BullpenSession.create(req.body, { include: [{ model: Pitch, as: 'pitches' }]} @@ -17,20 +32,31 @@ exports.insert = (req, res) => { }; exports.findAll = (req, res) => { - const { user } = req.query; - const filter = {}; - if (user) { - filter.pitcherId = parseInt(user, 10); - } - BullpenSession.findAll({ - where: filter, - include: { - model: Pitch, - as: 'pitches' - } + const { page, size, user } = req.query; + const condition = user ? { pitcherId: { [Op.eq]: parseInt(user, 10) } } : null; + + const { limit, offset } = getPagination(page, size); + + let totalCount = 0; + BullpenSession.count({ + where: condition, + limit, + offset + }).then(count => { + totalCount = count; + return BullpenSession.findAll({ + where: condition, + limit, + offset, + include: { + model: Pitch, + as: 'pitches' + } + }); }) .then(bullpenSessions => { - res.status(200).send(bullpenSessions); + const response = getPageData(bullpenSessions, totalCount, page, limit); + res.status(200).send(response); }); } diff --git a/backend/controllers/info.controller.js b/backend/controllers/info.controller.js new file mode 100644 index 0000000..8971f4b --- /dev/null +++ b/backend/controllers/info.controller.js @@ -0,0 +1,9 @@ +const process = require('process'); + +exports.info = (req, res) => { + res.json({ + message: "Welcome to bullpen", + version: process.env.npm_package_version, + environment: process.env.NODE_ENV, + }); +} diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml new file mode 100644 index 0000000..217a8f5 --- /dev/null +++ b/backend/docker-compose.test.yml @@ -0,0 +1,11 @@ +services: + test-db: + image: postgres:15 + ports: + - "5433:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: bullpen-test + volumes: + - /tmp/test-pgdata:/var/lib/postgresql/data diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..09b4703 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,44 @@ +services: + db: + image: postgres:15 + container_name: postgres + restart: always + secrets: + - db-password + environment: + POSTGRES_DB: bullpen-prod + POSTGRES_USER: test + POSTGRES_PASSWORD_FILE: /run/secrets/db-password + expose: + - 5432 + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD", "pg_isready", "-U", "test", "-d", "bullpen-prod"] + interval: 10s + timeout: 5s + retries: 5 + app: + build: + context: . + target: development + depends_on: + db: + condition: service_healthy + ports: + - "8124:8080" + container_name: bullpen + environment: + NODE_ENV: production + POSTGRES_HOST: db + POSTGRES_USER: test + POSTGRES_PASSWORD_FILE: /run/secrets/db-password + POSTGRES_DB: bullpen-prod + POSTGRES_PORT: 5432 + secrets: + - db-password +volumes: + db_data: +secrets: + db-password: + file: db/password.txt \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..6f400e0 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + // preset: 'ts-jest', + testEnvironment: 'node', + setupFilesAfterEnv: ['./jest.setup.js'] +}; diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..7037f5c --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,55 @@ +const db = require("./models/index"); +const bcrypt = require("bcryptjs"); + +const { beforeAll, afterAll } = require('@jest/globals'); + +const { Auth: Auth, User: User, Role: Role, PitchType: PitchType } = db; +const Op = db.Sequelize.Op; + +beforeAll(async () => { + await db.sequelize.sync({ force: true }); // Reset DB once for the entire test suite + await Role.destroy({ where: {} }); + await Role.bulkCreate([ + { name: 'player' }, + { name: 'coach' }, + { name: 'admin' } + ]); + const playerRole = await Role.findAll({ + where: { + name: { + [Op.eq]: 'player' + } + } + }); + + await Auth.destroy({ where: {} }); + await User.destroy({ where: {} }); + await Auth.create({ + email: 'player@example.com', password: 'hash1234' + }).then(auth => { + return User.create({ + firstName: 'Alice', + lastName: 'Player', + dateOfBirth: '1990-01-01', + gender: 'female', + handedness: 'right', + authId: auth.id + }); + }).then(user => { + return user.setRoles(playerRole); + }); + await PitchType.destroy({ where: {} }); + await PitchType.bulkCreate([ + { name: 'Fastball', abbreviation: 'FB' }, + { name: 'Curveball', abbreviation: 'CB' }, + { name: 'Slider', abbreviation: 'SL' }, + { name: 'Changeup', abbreviation: 'CH' }, + { name: 'Cutter', abbreviation: 'CUT' }, + { name: 'Sweeper', abbreviation: 'SW' }, + { name: 'Slurve', abbreviation: 'SLV' } + ]); +}); + +afterAll(async () => { + await db.sequelize.close(); // Close connection after all tests +}); \ No newline at end of file diff --git a/backend/models/index.js b/backend/models/index.js index df88aba..8eb2703 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,18 +1,23 @@ const fs = require('fs'); const path = require('path'); const Sequelize = require('sequelize'); -const process = require('process'); const basename = path.basename(__filename); +const dbConfigs = require('../config/config'); const env = process.env.NODE_ENV || 'development'; -const config = require(__dirname + '/../config/config.js')[env]; +const config = dbConfigs[env]; + const db = {}; -let sequelize; -if (config.use_env_variable) { - sequelize = new Sequelize(process.env[config.use_env_variable], config); -} else { - sequelize = new Sequelize(config.database, config.username, config.password, config); -} +const sequelize = new Sequelize( + config.database, + config.username, + config.password, { + host: config.host, + port: config.port, + dialect: config.dialect, + logging: config.logging + } +); fs .readdirSync(__dirname) diff --git a/backend/package-lock.json b/backend/package-lock.json index 69af56a..e4fb561 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,8 +25,10 @@ "devDependencies": { "@jest/globals": "^29.7.0", "cross-env": "^7.0.3", - "dotenv": "^16.4.7", + "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", "jest": "^29.7.0", + "nodemon": "^3.1.9", "sequelize-cli": "^6.6.2", "supertest": "^7.0.0" }, @@ -1627,6 +1629,19 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2020,6 +2035,31 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -2552,9 +2592,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2564,6 +2604,32 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/dottie": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", @@ -3461,6 +3527,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/glob/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3684,6 +3763,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -3794,6 +3880,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -3810,6 +3909,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3830,6 +3939,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -5633,6 +5755,82 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -6214,6 +6412,13 @@ "node": ">= 0.10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -6316,6 +6521,19 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6829,6 +7047,19 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7419,6 +7650,16 @@ "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", "license": "MIT" }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -7487,6 +7728,13 @@ "node": ">=6.0.0" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", diff --git a/backend/package.json b/backend/package.json index 636caf3..7c9a487 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,8 +7,16 @@ "pretest": "cross-env NODE_ENV=test npm run db:reset", "db:create:test": "cross-env NODE_ENV=test npx sequelize-cli db:create", "db:reset": "npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npx sequelize-cli db:seed:all --debug", + "db:reset:dev": "cross-env NODE_ENV=development npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npm run seed:dev", + "db:reset:prod": "cross-env NODE_ENV=production npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npm run seed:prod", "setup-db": "npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npx sequelize-cli db:seed:all --debug", - "start": "cross-env NODE_ENV=test node server.js" + "seed:dev": "NODE_ENV=development npx sequelize-cli db:seed:all --debug", + "start:prod": "npm run cross-env NODE_ENV=production node server.js", + "start:dev": "cross-env NODE_ENV=development nodemon server.js", + "test:db:start": "docker-compose -f docker-compose.test.yml up -d", + "test:db:stop": "docker-compose -f docker-compose.test.yml down -v", + "test:run": "dotenv -e .env.test -- jest --runInBand --detectOpenHandles", + "test:full": "npm run test:db:start && npm run test:run && npm run test:db:stop" }, "engines": { "node": ">=10" @@ -33,8 +41,10 @@ "devDependencies": { "@jest/globals": "^29.7.0", "cross-env": "^7.0.3", - "dotenv": "^16.4.7", + "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", "jest": "^29.7.0", + "nodemon": "^3.1.9", "sequelize-cli": "^6.6.2", "supertest": "^7.0.0" } diff --git a/backend/publish.sh b/backend/publish.sh new file mode 100644 index 0000000..2fdb38b --- /dev/null +++ b/backend/publish.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Replace with your actual Gitea Docker registry +REGISTRY_URL=git.palaeomatiker.home64.de +IMAGE_NAME=express-sequelize-app +TAG=latest + +# Authenticate with Docker registry +echo "Logging into Docker registry..." +docker login $REGISTRY_URL + +# Build the image +echo "Building Docker image..." +docker build -t $IMAGE_NAME . + +# Tag the image +echo "Tagging image..." +docker tag $IMAGE_NAME $REGISTRY_URL/$IMAGE_NAME:$TAG + +# Push the image +echo "Pushing to Gitea registry..." +docker push $REGISTRY_URL/$IMAGE_NAME:$TAG diff --git a/backend/routes/info.routes.js b/backend/routes/info.routes.js new file mode 100644 index 0000000..358bea3 --- /dev/null +++ b/backend/routes/info.routes.js @@ -0,0 +1,13 @@ +const controller = require("../controllers/info.controller"); + +module.exports = function(app) { + app.use(function(req, res, next) { + res.header( + "Access-Control-Allow-Headers", + "x-access-token, Origin, Content-Type, Accept" + ); + next(); + }); + + app.get("/", controller.info); +}; diff --git a/backend/seeders/02-users-development.js b/backend/seeders/02-users-development.js new file mode 100644 index 0000000..70bc567 --- /dev/null +++ b/backend/seeders/02-users-development.js @@ -0,0 +1,99 @@ +const bcrypt = require("bcryptjs"); +const process = require('process'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'development') return; + + await queryInterface.bulkInsert('Authentications', [ + { email: 'ryan.nolan@bullpen.com', password: bcrypt.hashSync('ryan$123', 8), createdAt: new Date(), updatedAt: new Date() }, + { email: 'sandy.koufax@bullpen.com', password: bcrypt.hashSync('sandy$123', 8), createdAt: new Date(), updatedAt: new Date() }, + { email: 'pedro.martinez@bullpen.com', password: bcrypt.hashSync('pedro$123', 8), createdAt: new Date(), updatedAt: new Date() }, + { email: 'randy.johnson@bullpen.com', password: bcrypt.hashSync('randy$123', 8), createdAt: new Date(), updatedAt: new Date() }, + { email: 'sparky.anderson@bullpen.com', password: bcrypt.hashSync('sparky$123', 8), createdAt: new Date(), updatedAt: new Date() }, + ]); + + const auths = await queryInterface.select(null, 'Authentications'); + const ryanAuthId = auths.filter((auth) => auth.email === 'ryan.nolan@bullpen.com').map((auth) => auth.id).shift(); + const sandyAuthId = auths.filter((auth) => auth.email === 'sandy.koufax@bullpen.com').map((auth) => auth.id).shift(); + const pedroAuthId = auths.filter((auth) => auth.email === 'pedro.martinez@bullpen.com').map((auth) => auth.id).shift(); + const randyAuthId = auths.filter((auth) => auth.email === 'randy.johnson@bullpen.com').map((auth) => auth.id).shift(); + const sparkyAuthId = auths.filter((auth) => auth.email === 'sparky.anderson@bullpen.com').map((auth) => auth.id).shift(); + + await queryInterface.bulkInsert('Users', [{ + firstName: 'Nolan', + lastName: 'Ryan', + dateOfBirth: new Date(1947, 1, 31), + gender: 'male', + handedness: 'right', + authId: ryanAuthId, + createdAt: new Date(), + updatedAt: new Date() + }, { + firstName: 'Sandy', + lastName: 'Koufax', + dateOfBirth: new Date(1935, 12, 30), + gender: 'male', + handedness: 'right', + authId: sandyAuthId, + createdAt: new Date(), + updatedAt: new Date() + }, { + firstName: 'Pedro', + lastName: 'Martinez', + dateOfBirth: new Date(1971, 10, 25), + gender: 'male', + handedness: 'right', + authId: pedroAuthId, + createdAt: new Date(), + updatedAt: new Date() + }, { + firstName: 'randy', + lastName: 'johnson', + dateOfBirth: new Date(1963, 9, 10), + gender: 'male', + handedness: 'right', + authId: randyAuthId, + createdAt: new Date(), + updatedAt: new Date() + }, { + firstName: 'Sparky', + lastName: 'Anderson', + dateOfBirth: new Date(1934, 22, 2), + gender: 'male', + authId: sparkyAuthId, + createdAt: new Date(), + updatedAt: new Date(), + }]); + + const users = await queryInterface.select(null, 'Users'); + const ryanId = users.filter((user) => user.firstName === 'Ryan').map((user) => user.id).shift(); + const sandyId = users.filter((user) => user.firstName === 'Sandy').map((user) => user.id).shift(); + const pedroId = users.filter((user) => user.firstName === 'Pedro').map((user) => user.id).shift(); + const randyId = users.filter((user) => user.firstName === 'Randy').map((user) => user.id).shift(); + const sparkyId = users.filter((user) => user.firstName === 'Sparky').map((user) => user.id).shift(); + + const roles = await queryInterface.select(null, 'Roles'); + const playerId = roles.filter((role) => role.name === 'player').map((role) => role.id).shift(); + const coachId = roles.filter((role) => role.name === 'coach').map((role) => role.id).shift(); + const adminId = roles.filter((role) => role.name === 'admin').map((role) => role.id).shift(); + + await queryInterface.bulkInsert('UserRoles', [ + { userId: ryanId, roleId: playerId, createdAt: new Date(), updatedAt: new Date() }, + { userId: sandyId, roleId: playerId, createdAt: new Date(), updatedAt: new Date() }, + { userId: pedroId, roleId: playerId, createdAt: new Date(), updatedAt: new Date() }, + { userId: randyId, roleId: playerId, createdAt: new Date(), updatedAt: new Date() }, + { userId: sparkyId, roleId: coachId, createdAt: new Date(), updatedAt: new Date() }, + { userId: sparkyId, roleId: adminId, createdAt: new Date(), updatedAt: new Date() }, + ]); + }, + + async down (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'development') return; + + await queryInterface.dropTable('Authentications'); + await queryInterface.dropTable('Users'); + await queryInterface.dropTable('UserRoles'); + } +}; diff --git a/backend/seeders/02-users-production.js b/backend/seeders/02-users-production.js new file mode 100644 index 0000000..3586e86 --- /dev/null +++ b/backend/seeders/02-users-production.js @@ -0,0 +1,46 @@ +const bcrypt = require("bcryptjs"); +const process = require('process'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'production') return; + + await queryInterface.bulkInsert('Authentications', [ + { email: 'admin@example.com', password: bcrypt.hashSync('admin$123', 8), createdAt: new Date(), updatedAt: new Date() } + ]); + + const auths = await queryInterface.select(null, 'Authentications'); + const adminAuthId = auths.filter((auth) => auth.email === 'admin@example.com').map((auth) => auth.id).shift(); + + await queryInterface.bulkInsert('Users', [{ + firstName: 'Admin', + lastName: 'Bullpen', + dateOfBirth: '1970-01-01', + gender: 'other', + authId: adminAuthId, + createdAt: new Date(), + updatedAt: new Date(), + }] + ); + + const users = await queryInterface.select(null, 'Users'); + const adminUserId = users.filter((user) => user.firstName === 'Admin').map((user) => user.id).shift(); + + const roles = await queryInterface.select(null, 'Roles'); + const adminRoleId = roles.filter((role) => role.name === 'admin').map((role) => role.id).shift(); + + await queryInterface.bulkInsert('UserRoles', [ + { userId: adminUserId, roleId: adminRoleId, createdAt: new Date(), updatedAt: new Date() } + ]); + + }, + + async down (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'production') return; + + await queryInterface.dropTable('Authentications'); + await queryInterface.dropTable('Users'); + await queryInterface.dropTable('UserRoles'); + } +}; diff --git a/backend/seeders/02-users.js b/backend/seeders/02-users-test.js similarity index 71% rename from backend/seeders/02-users.js rename to backend/seeders/02-users-test.js index 0a577f5..acb4656 100644 --- a/backend/seeders/02-users.js +++ b/backend/seeders/02-users-test.js @@ -1,8 +1,11 @@ const bcrypt = require("bcryptjs"); +const process = require('process'); /** @type {import('sequelize-cli').Migration} */ module.exports = { async up (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'test') return; + await queryInterface.bulkInsert('Authentications', [ { email: 'player@example.com', password: bcrypt.hashSync('hash1234', 8), createdAt: new Date(), updatedAt: new Date() }, { email: 'coach@example.com', password: bcrypt.hashSync('hash2345', 8), createdAt: new Date(), updatedAt: new Date() }, @@ -15,33 +18,33 @@ module.exports = { const adminAuthId = auths.filter((auth) => auth.email === 'admin@example.com').map((auth) => auth.id).shift(); await queryInterface.bulkInsert('Users', [{ - firstName: 'Alice', - lastName: 'Player', - dateOfBirth: '1990-01-01', - gender: 'female', - handedness: 'right', - authId: playerAuthId, - createdAt: new Date(), - updatedAt: new Date(), - }, { - firstName: 'Bob', - lastName: 'Coach', - dateOfBirth: '1985-05-05', - gender: 'male', - handedness: 'left', - authId: coachAuthId, - createdAt: new Date(), - updatedAt: new Date(), - }, { - firstName: 'Charlie', - lastName: 'Admin', - dateOfBirth: '1980-03-03', - gender: 'other', - handedness: 'both', - authId: adminAuthId, - createdAt: new Date(), - updatedAt: new Date(), - }] + firstName: 'Alice', + lastName: 'Player', + dateOfBirth: '1990-01-01', + gender: 'female', + handedness: 'right', + authId: playerAuthId, + createdAt: new Date(), + updatedAt: new Date(), + }, { + firstName: 'Bob', + lastName: 'Coach', + dateOfBirth: '1985-05-05', + gender: 'male', + handedness: 'left', + authId: coachAuthId, + createdAt: new Date(), + updatedAt: new Date(), + }, { + firstName: 'Charlie', + lastName: 'Admin', + dateOfBirth: '1980-03-03', + gender: 'other', + handedness: 'both', + authId: adminAuthId, + createdAt: new Date(), + updatedAt: new Date(), + }] ); const users = await queryInterface.select(null, 'Users'); @@ -59,10 +62,11 @@ module.exports = { { userId: bobId, roleId: coachId, createdAt: new Date(), updatedAt: new Date() }, { userId: charlieId, roleId: adminId, createdAt: new Date(), updatedAt: new Date() }, ]); - }, async down (queryInterface, /*Sequelize*/) { + if (process.env.NODE_ENV !== 'test') return; + await queryInterface.dropTable('Authentications'); await queryInterface.dropTable('Users'); await queryInterface.dropTable('UserRoles'); diff --git a/backend/server.js b/backend/server.js index 87baa81..e2aad5c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,7 +1,14 @@ const app = require('./app'); +require('dotenv').config({ + path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env' +}); + +const dbConfigs = require('./config/config'); +const env = process.env.NODE_ENV || 'development'; +const config = dbConfigs[env]; // set port, listen for requests const PORT = process.env.PORT || 8080; app.listen(PORT, () => { - console.log(`Server is running on port ${PORT}.`); + console.log(`Server is running on port ${PORT} in ${process.env.NODE_ENV} mode.`); }); diff --git a/backend/test/bullpenSession.test.js b/backend/test/bullpenSession.test.js index ddbec87..d4a6aa0 100644 --- a/backend/test/bullpenSession.test.js +++ b/backend/test/bullpenSession.test.js @@ -9,6 +9,14 @@ const app = require("../app") const { bullpenSession } = require("./data/bullpenSession.test.data") +const validateBullpenSession = (bullpenSession) => { + expect(bullpenSession.id).toBeDefined(); + expect(bullpenSession.id).toBeGreaterThan(0); + expect(bullpenSession.pitches).toBeDefined(); + expect(Array.isArray(bullpenSession.pitches)).toBe(true); + expect(bullpenSession.pitches.length).toBe(2); +} + describe("Test bullpen session", () => { test("should create bullpen session with pitches", async () => { let response = await request(app) @@ -27,14 +35,10 @@ describe("Test bullpen session", () => { expect(response.statusCode).toBe(201); const bullpenSessionData = await response.body; expect(bullpenSessionData).toBeDefined(); - expect(bullpenSessionData.id).toBeDefined(); - expect(bullpenSessionData.id).toBeGreaterThan(0); - expect(bullpenSessionData.pitches).toBeDefined(); - expect(Array.isArray(bullpenSessionData.pitches)).toBe(true); - expect(bullpenSessionData.pitches.length).toBe(2); + validateBullpenSession(bullpenSessionData); }); - test.skip("should get all bullpen sessions", async () => { + test("should get all bullpen sessions", async () => { let response = await request(app) .post("/api/auth/login") .send({ @@ -48,16 +52,15 @@ describe("Test bullpen session", () => { .set('x-access-token', user.accessToken); expect(response.statusCode).toBe(200); - const bullpenSessionDataArray = await response.body; - expect(bullpenSessionDataArray).toBeDefined(); - expect(Array.isArray(bullpenSessionDataArray)).toBe(true); - expect(bullpenSessionDataArray.length).toBe(1); - const bullpenSessionData = bullpenSessionDataArray[0]; - expect(bullpenSessionData.id).toBeDefined(); - expect(bullpenSessionData.id).toBeGreaterThan(0); - expect(bullpenSessionData.pitches).toBeDefined(); - expect(Array.isArray(bullpenSessionData.pitches)).toBe(true); - expect(bullpenSessionData.pitches.length).toBe(2); + const { totalCount, data, totalPages, currentPage } = await response.body; + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(totalCount).toBe(1); + expect(totalPages).toBe(1); + expect(currentPage).toBe(0); + const bullpenSessionData = data[0]; + validateBullpenSession(bullpenSessionData); }) test("should get all bullpen sessions for user", async () => { @@ -75,15 +78,14 @@ describe("Test bullpen session", () => { .query({ user: user.id }); expect(response.statusCode).toBe(200); - const bullpenSessionDataArray = await response.body; - expect(bullpenSessionDataArray).toBeDefined(); - expect(Array.isArray(bullpenSessionDataArray)).toBe(true); - expect(bullpenSessionDataArray.length).toBe(1); - const bullpenSessionData = bullpenSessionDataArray[0]; - expect(bullpenSessionData.id).toBeDefined(); - expect(bullpenSessionData.id).toBeGreaterThan(0); - expect(bullpenSessionData.pitches).toBeDefined(); - expect(Array.isArray(bullpenSessionData.pitches)).toBe(true); - expect(bullpenSessionData.pitches.length).toBe(2); + const { totalCount, data, totalPages, currentPage } = await response.body; + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(totalCount).toBe(1); + expect(totalPages).toBe(1); + expect(currentPage).toBe(0); + const bullpenSessionData = data[0]; + validateBullpenSession(bullpenSessionData); }) }) diff --git a/backend/test/pitchType.test.js b/backend/test/pitchType.test.js index 2282e5e..fbb6290 100644 --- a/backend/test/pitchType.test.js +++ b/backend/test/pitchType.test.js @@ -2,7 +2,7 @@ const request = require("supertest") const { expect, describe, - test, + test, beforeAll, afterAll, } = require('@jest/globals'); const app = require("../app") diff --git a/backend/test/user.test.js b/backend/test/user.test.js index 12e94d7..f1db6f7 100644 --- a/backend/test/user.test.js +++ b/backend/test/user.test.js @@ -18,7 +18,7 @@ describe("Test user authentication", () => { expect(response.statusCode).toBe(200); }); - test("Test user login", async() => { + test("should login user", async() => { let response = await request(app) .post("/api/auth/login") .send({