├── .github ├── FUNDING.yml ├── pull_request_template.md ├── dependabot.yml ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── workflows │ └── dockerpush.yml ├── .gitignore ├── test ├── config.js ├── venues.js ├── coach.js ├── draft.js ├── lines.js ├── stats.js ├── ratings.js ├── players.js └── games.js ├── README.md ├── .dockerignore ├── config ├── passport.js ├── swagger.js ├── apm.js ├── rabbit.js ├── consumers.js ├── strategies │ └── bearer.js ├── brute.js ├── database.js ├── slowdown.js └── express.js ├── app ├── coach │ ├── coach.route.js │ ├── coach.controller.js │ └── coach.service.js ├── venue │ ├── venue.route.js │ ├── venue.service.js │ └── venue.controller.js ├── rankings │ ├── rankings.route.js │ └── rankings.controller.js ├── live │ ├── live.route.js │ ├── live.controller.js │ └── live.service.js ├── lines │ ├── lines.route.js │ ├── lines.controller.js │ └── lines.service.js ├── auth │ ├── auth.route.js │ ├── auth.service.js │ └── auth.controller.js ├── draft │ ├── draft.route.js │ ├── draft.controller.js │ └── draft.service.js ├── play │ ├── play.route.js │ ├── play.controller.js │ └── play.service.js ├── recruiting │ ├── recruiting.route.js │ └── recruiting.controller.js ├── team │ ├── team.route.js │ └── team.controller.js ├── ratings │ ├── ratings.route.js │ ├── ratings.controller.js │ └── ratings.service.js ├── stats │ ├── stats.routes.js │ └── stats.controller.js ├── player │ ├── player.routes.js │ └── player.controller.js ├── ppa │ ├── ppa.routes.js │ └── ppa.controller.js ├── game │ ├── game.route.js │ └── game.service.js └── events │ └── events.route.js ├── .eslintrc ├── Dockerfile ├── server.js ├── LICENSE └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: collegefootballdata 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .env 4 | /doc 5 | *.log 6 | *.bak 7 | *.save 8 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | const db = require('../config/database')().db; 2 | 3 | module.exports.db = db; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cfb-api 2 | College football API - Programming interface for accessing the college football database 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile 4 | .dockerignore 5 | .git 6 | .gitignore 7 | *.log 8 | *.log.bak 9 | -------------------------------------------------------------------------------- /config/passport.js: -------------------------------------------------------------------------------- 1 | const bearerStrategy = require('./strategies/bearer'); 2 | 3 | module.exports = (passport, authDb) => { 4 | bearerStrategy(passport, authDb); 5 | }; 6 | -------------------------------------------------------------------------------- /app/coach/coach.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./coach.controller')(db, Sentry); 3 | 4 | app.route('/coaches').get(middlewares, controller.getCoaches); 5 | } -------------------------------------------------------------------------------- /app/venue/venue.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./venue.controller')(db, Sentry); 3 | 4 | app.route('/venues').get(middlewares, controller.getVenues); 5 | } -------------------------------------------------------------------------------- /app/rankings/rankings.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./rankings.controller')(db, Sentry); 3 | 4 | app.route('/rankings').get(middlewares, controller.getRankings); 5 | } -------------------------------------------------------------------------------- /app/live/live.route.js: -------------------------------------------------------------------------------- 1 | module.exports = async (app, db, patreonMiddlewares, Sentry) => { 2 | const controller = await require('./live.controller')(db, Sentry); 3 | 4 | app.route('/live/plays').get(patreonMiddlewares, controller.getPlays); 5 | }; 6 | -------------------------------------------------------------------------------- /app/lines/lines.route.js: -------------------------------------------------------------------------------- 1 | const linesController = require('./lines.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = linesController(db, Sentry); 5 | 6 | app.route('/lines').get(middlewares, controller.getLines); 7 | }; 8 | -------------------------------------------------------------------------------- /config/swagger.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, cors) => { 2 | const swaggerSpec = require('../swagger'); 3 | 4 | app.get('/api-docs.json', cors(), (req, res) => { 5 | res.setHeader('Content-Type', 'application/json'); 6 | res.send(swaggerSpec); 7 | }); 8 | } -------------------------------------------------------------------------------- /app/auth/auth.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, cors, Sentry, patreonAuth) => { 2 | const controller = require('./auth.controller')(Sentry); 3 | 4 | app.options('/auth/key', cors); 5 | app.route('/auth/key').post(cors, controller.generateKey); 6 | app.route('/auth/graphql').get(patreonAuth, controller.graphQLAuth); 7 | }; 8 | -------------------------------------------------------------------------------- /app/auth/auth.service.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | module.exports = () => { 4 | const endpoint = process.env.AUTH_URL; 5 | 6 | const generateKey = async (email) => { 7 | await axios.post(endpoint, { 8 | email 9 | }); 10 | }; 11 | 12 | return { 13 | generateKey 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /app/draft/draft.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./draft.controller')(db, Sentry); 3 | 4 | app.route('/draft/teams').get(middlewares, controller.getTeams); 5 | app.route('/draft/positions').get(middlewares, controller.getPositions); 6 | app.route('/draft/picks').get(middlewares, controller.getPicks); 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "plugins": [ 7 | "node", 8 | "security" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:node/recommended", 13 | "plugin:security/recommended" 14 | ], 15 | "parserOptions": { 16 | "ecmaVersion": 2017 17 | } 18 | } -------------------------------------------------------------------------------- /app/play/play.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./play.controller')(db, Sentry); 3 | 4 | app.route('/plays').get(middlewares, controller.getPlays); 5 | app.route('/play/types').get(middlewares, controller.getPlayTypes); 6 | app.route('/play/stat/types').get(middlewares, controller.getPlayStatTypes); 7 | app.route('/play/stats').get(middlewares, controller.getPlayStats); 8 | } -------------------------------------------------------------------------------- /test/venues.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const service = require('../app/venue/venue.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Venues', () => { 8 | it('it should get a list of venues', async () => { 9 | const venues = await service.getVenues(); 10 | 11 | venues.should.be.an('array'); 12 | venues.length.should.be.gt(0); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | RUN mkdir -p /home/node/app/node_modules && mkdir -p /home/node/app/dist && chown -R node:node /home/node/app 4 | 5 | WORKDIR /home/node/app 6 | 7 | COPY package*.json ./ 8 | 9 | RUN npm install pm2 -g 10 | RUN npm install 11 | 12 | COPY . . 13 | COPY --chown=node:node . . 14 | 15 | RUN npm start 16 | 17 | USER node 18 | 19 | EXPOSE 8080 20 | 21 | CMD [ "pm2-runtime", "server.js" ] 22 | -------------------------------------------------------------------------------- /app/recruiting/recruiting.route.js: -------------------------------------------------------------------------------- 1 | const recruitingController = require('./recruiting.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = recruitingController(db, Sentry); 5 | 6 | app.route('/recruiting/players').get(middlewares, controller.getPlayers); 7 | app.route('/recruiting/groups').get(middlewares, controller.getAggregatedPlayers); 8 | app.route('/recruiting/teams').get(middlewares, controller.getTeams); 9 | }; 10 | -------------------------------------------------------------------------------- /app/venue/venue.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getVenues = async () => { 3 | const venues = await db.any(` 4 | SELECT id, name, capacity, grass, city, state, zip, country_code, location, elevation, year_constructed, dome, timezone 5 | FROM venue 6 | ORDER BY name 7 | `); 8 | 9 | return venues; 10 | }; 11 | 12 | return { 13 | getVenues 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const Sentry = require('@sentry/node'); 2 | 3 | (async() => { 4 | require('dotenv').config(); 5 | Sentry.init({ dsn: `https://${process.env.SENTRY_KEY}@sentry.io/${process.env.SENTRY_ID}`, debug: process.env.NODE_ENV != 'production' }); 6 | 7 | const express = require('./config/express'); 8 | const app = await express(Sentry); 9 | 10 | app.listen(process.env.PORT, console.log(`Server running on port ${process.env.PORT}`)); //eslint-disable-line 11 | })().catch(err => { 12 | Sentry.captureException(err); 13 | }); -------------------------------------------------------------------------------- /app/venue/venue.controller.js: -------------------------------------------------------------------------------- 1 | module.exports = (db, Sentry) => { 2 | const service = require('./venue.service')(db); 3 | 4 | return { 5 | getVenues: async (req, res) => { 6 | try { 7 | const venues = await service.getVenues(); 8 | res.send(venues); 9 | } catch (err) { 10 | Sentry.captureException(err); 11 | res.status(500).send({ 12 | error: 'Something went wrong.' 13 | }); 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/team/team.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry) => { 2 | const controller = require('./team.controller')(db, Sentry); 3 | 4 | app.route('/teams').get(middlewares, controller.getTeams); 5 | app.route('/teams/fbs').get(middlewares, controller.getFBSTeams); 6 | app.route('/roster').get(middlewares, controller.getRoster); 7 | app.route('/conferences').get(middlewares, controller.getConferences); 8 | app.route('/talent').get(middlewares, controller.getTeamTalent); 9 | app.route('/teams/matchup').get(middlewares, controller.getMatchup); 10 | } -------------------------------------------------------------------------------- /app/ratings/ratings.route.js: -------------------------------------------------------------------------------- 1 | const controllerConstructor = require('./ratings.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = controllerConstructor(db, Sentry); 5 | 6 | app.route('/ratings/sp').get(middlewares, controller.getSP); 7 | app.route('/ratings/sp/conferences').get(middlewares, controller.getConferenceSP); 8 | app.route('/ratings/srs').get(middlewares, controller.getSRS); 9 | app.route('/ratings/elo').get(middlewares, controller.getElo); 10 | app.route('/ratings/fpi').get(middlewares, controller.getFpi); 11 | }; 12 | -------------------------------------------------------------------------------- /config/apm.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | return async (req, res, next) => { 3 | if (process.env.ENABLE_METRICS == 'true' && req.user && req.user.id) { 4 | try { 5 | await db.none(` 6 | INSERT INTO metrics (user_id, endpoint, query, user_agent) 7 | VALUES ($1, $2, $3, $4) 8 | `, [req.user.id, req.sws.api_path, req.query, req.get('user-agent')]); 9 | } catch (err) { 10 | console.error(err); 11 | next(); 12 | } 13 | } 14 | 15 | next(); 16 | }; 17 | }; -------------------------------------------------------------------------------- /app/stats/stats.routes.js: -------------------------------------------------------------------------------- 1 | const controllerConstructor = require('./stats.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = controllerConstructor(db, Sentry); 5 | 6 | app.route('/stats/season').get(middlewares, controller.getTeamStats); 7 | app.route('/stats/season/advanced').get(middlewares, controller.getAdvancedStats); 8 | app.route('/stats/categories').get(middlewares, controller.getCategories); 9 | app.route('/stats/game/advanced').get(middlewares, controller.getAdvancedGameStats); 10 | app.route('/game/box/advanced').get(middlewares, controller.getAdvancedBoxScore); 11 | } -------------------------------------------------------------------------------- /app/player/player.routes.js: -------------------------------------------------------------------------------- 1 | const playerController = require('./player.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = playerController(db, Sentry); 5 | 6 | app.route('/player/search').get(middlewares, controller.playerSearch); 7 | app.route('/player/ppa/passing').get(middlewares, controller.getMeanPassingPPA); 8 | app.route('/player/usage').get(middlewares, controller.getPlayerUsage); 9 | app.route('/player/returning').get(middlewares, controller.getReturningProduction); 10 | app.route('/stats/player/season').get(middlewares, controller.getSeasonStats); 11 | app.route('/player/portal').get(middlewares, controller.getTransferPortal); 12 | }; -------------------------------------------------------------------------------- /config/rabbit.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | const amqp = require('amqplib'); 3 | const url = process.env.RABBIT_URL; 4 | const vHost = process.env.RABBIT_VIRTUAL_HOST; 5 | const user = process.env.RABBIT_USER; 6 | const password = process.env.RABBIT_PASSWORD; 7 | 8 | const connection = await amqp.connect(`amqp://${user}:${password}@${url}/${vHost}`); 9 | const channel = await connection.createChannel(); 10 | 11 | const publish = async (eventType, message) => { 12 | await channel.assertExchange(eventType, 'fanout'); 13 | channel.publish(eventType, '', Buffer.from(JSON.stringify(message))); 14 | }; 15 | 16 | return { 17 | channel, 18 | publish 19 | }; 20 | }; -------------------------------------------------------------------------------- /config/consumers.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | const rabbit = await require('./rabbit')(); 3 | const channel = rabbit.channel; 4 | const createQueue = async (exchangeName, action) => { 5 | channel.assertExchange(exchangeName, 'fanout'); 6 | 7 | const q = await channel.assertQueue('', { 8 | exclusive: true 9 | }); 10 | 11 | channel.bindQueue(q.queue, exchangeName, ''); 12 | 13 | channel.consume(q.queue, (message) => { 14 | if (message.content) { 15 | action(JSON.parse(message.content.toString())); 16 | } 17 | }, { 18 | noAck: true 19 | }); 20 | }; 21 | 22 | return { 23 | createQueue 24 | } 25 | }; -------------------------------------------------------------------------------- /app/ppa/ppa.routes.js: -------------------------------------------------------------------------------- 1 | const controllerConstructor = require('./ppa.controller'); 2 | 3 | module.exports = (app, db, middlewares, Sentry) => { 4 | const controller = controllerConstructor(db, Sentry); 5 | 6 | app.route('/ppa/predicted').get(middlewares, controller.getPP); 7 | app.route('/ppa/teams').get(middlewares, controller.getPPAByTeam); 8 | app.route('/ppa/games').get(middlewares, controller.getPPAByGame); 9 | app.route('/ppa/players/games').get(middlewares, controller.getPPAByPlayerGame); 10 | app.route('/ppa/players/season').get(middlewares, controller.getPPAByPlayerSeason); 11 | app.route('/metrics/wp').get(middlewares, controller.getWP); 12 | app.route('/metrics/wp/pregame').get(middlewares, controller.getPregameWP); 13 | app.route('/metrics/fg/ep').get(middlewares, controller.getFGEP); 14 | } -------------------------------------------------------------------------------- /config/strategies/bearer.js: -------------------------------------------------------------------------------- 1 | const Strategy = require('passport-http-bearer').Strategy; 2 | 3 | module.exports = (passport, authDb) => { 4 | passport.use(new Strategy(async (token, cb) => { 5 | try { 6 | const user = await authDb.oneOrNone(`SELECT * FROM "user" WHERE token = $1`, token); 7 | 8 | if (user) { 9 | return cb(null, { 10 | id: user.id, 11 | username: user.username, 12 | patronLevel: user.patron_level, 13 | blacklisted: user.blacklisted, 14 | throttled: user.throttled 15 | }); 16 | } else { 17 | return cb(null, false); 18 | } 19 | } catch (err) { 20 | return cb(err); 21 | } 22 | })); 23 | }; -------------------------------------------------------------------------------- /app/game/game.route.js: -------------------------------------------------------------------------------- 1 | module.exports = (app, db, middlewares, Sentry, patreonMiddlewares) => { 2 | const controller = require('./game.controller')(db, Sentry); 3 | 4 | app.route('/games').get(middlewares, controller.getGames); 5 | app.route('/games/teams').get(middlewares, controller.getTeamStats); 6 | app.route('/drives').get(middlewares, controller.getDrives); 7 | app.route('/games/players').get(middlewares, controller.getPlayerStats); 8 | app.route('/records').get(middlewares, controller.getRecords); 9 | app.route('/games/media').get(middlewares, controller.getMedia); 10 | app.route('/calendar').get(middlewares, controller.getCalendar); 11 | app.route('/games/weather').get(patreonMiddlewares, controller.getWeather); 12 | app.route('/scoreboard').get(patreonMiddlewares, controller.getScoreboard); 13 | } -------------------------------------------------------------------------------- /app/live/live.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./live.service'); 2 | 3 | module.exports = async (db, Sentry) => { 4 | const service = await serviceConstructor(db); 5 | 6 | const getPlays = async (req, res) => { 7 | try { 8 | if (!req.query.id && !parseInt(req.query.id)) { 9 | res.status(400).send({ 10 | error: 'A numeric game id is required.' 11 | }); 12 | } else { 13 | let results = await service.getPlays(req.query.id); 14 | res.send(results); 15 | } 16 | } catch (err) { 17 | Sentry.captureException(err); 18 | res.status(500).send({ 19 | error: 'Something went wrong.' 20 | }); 21 | } 22 | }; 23 | 24 | return { 25 | getPlays 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /config/brute.js: -------------------------------------------------------------------------------- 1 | const ExpressBrute = require('express-brute'); 2 | const PgStore = require('express-brute-pg'); 3 | 4 | module.exports = () => { 5 | const authDbUser = process.env.AUTH_DATABASE_USER; 6 | const authDbPassword = process.env.AUTH_DATABASE_PASSWORD; 7 | const authDbName = process.env.AUTH_DATABASE; 8 | const host = process.env.HOST; 9 | const port = process.env.DATABASE_PORT; 10 | 11 | var store = new PgStore({ 12 | host, 13 | port, 14 | database: authDbName, 15 | user: authDbUser, 16 | password: authDbPassword 17 | }); 18 | 19 | const bruteforce = new ExpressBrute(store, { 20 | freeRetries: 2, 21 | minWait: 1*60*60*1000, 22 | maxWait: 1*60*60*1000, 23 | failCallback: (req, res, next, nextValidRequestDate) => { 24 | res.sendStatus(200); 25 | }, 26 | handleStoreError: (error) => { 27 | console.error(error); 28 | } 29 | }); 30 | 31 | return bruteforce; 32 | }; 33 | -------------------------------------------------------------------------------- /config/database.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const user = process.env.DATABASE_USER; 3 | const password = process.env.DATABASE_PASSWORD; 4 | const host = process.env.HOST; 5 | const port = process.env.DATABASE_PORT; 6 | const dbName = process.env.DATABASE; 7 | 8 | const authDbHost = process.env.AUTH_DATABASE_HOST; 9 | const authDbUser = process.env.AUTH_DATABASE_USER; 10 | const authDbPassword = process.env.AUTH_DATABASE_PASSWORD; 11 | const authDbName = process.env.AUTH_DATABASE; 12 | 13 | const pgp = require('pg-promise'); 14 | const promise = require('bluebird'); 15 | 16 | const connectionString = `postgres://${user}:${password}@${host}:${port}/${dbName}`; 17 | const authConnectionString = `postgres://${authDbUser}:${authDbPassword}@${authDbHost}:${port}/${authDbName}`; 18 | 19 | const dbCreator = pgp({ 20 | promiseLib: promise 21 | }); 22 | 23 | const db = dbCreator(connectionString); 24 | const authDb = dbCreator(authConnectionString); 25 | 26 | return { 27 | connectionString, 28 | db, 29 | authDb 30 | } 31 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfb-api", 3 | "version": "1.0.0", 4 | "description": "API for accessing data from the college football database (cfbdb)", 5 | "engines": { 6 | "node": ">=12.0.0" 7 | }, 8 | "main": "server.js", 9 | "scripts": { 10 | "test": "mocha --parallel --timeout 100000" 11 | }, 12 | "keywords": [ 13 | "football", 14 | "college", 15 | "cfb", 16 | "ncaaf", 17 | "data", 18 | "statistics" 19 | ], 20 | "author": "BlueSCar", 21 | "license": "MIT", 22 | "dependencies": { 23 | "@sentry/node": "^6.2.5", 24 | "amqplib": "^0.8.0", 25 | "axios": "^0.21.4", 26 | "bluebird": "^3.7.2", 27 | "body-parser": "^1.19.0", 28 | "cookie-parser": "^1.4.4", 29 | "cookie-session": "^2.0.0-rc.1", 30 | "cors": "^2.8.5", 31 | "dotenv": "^8.2.0", 32 | "express": "^4.17.1", 33 | "express-brute": "^1.0.1", 34 | "express-brute-pg": "^2.0.0", 35 | "express-slow-down": "^1.4.0", 36 | "express-ws": "^4.0.0", 37 | "gaussian": "^1.1.0", 38 | "helmet": "^4.2.0", 39 | "passport": "^0.4.1", 40 | "passport-http-bearer": "^1.0.1", 41 | "pg": "^7.18.2", 42 | "pg-promise": "^10.9.5", 43 | "swagger-stats": "^0.95.17", 44 | "swagger-ui-dist": "^3.30.2" 45 | }, 46 | "devDependencies": { 47 | "chai": "^4.3.4", 48 | "chai-http": "^4.3.0", 49 | "mocha": "^8.3.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/lines/lines.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./lines.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | const getLines = async (req, res) => { 7 | try { 8 | if (!req.query.year) { 9 | res.status(400).send({ 10 | error: 'year parameter is required.' 11 | }); 12 | 13 | return; 14 | } else if (isNaN(req.query.year)) { 15 | res.status(400).send({ 16 | error: 'A numeric year parameter must be specified.' 17 | }); 18 | 19 | return; 20 | } else if (req.query.week && isNaN(req.query.week)) { 21 | res.status(400).send({ 22 | error: 'Week parameter must be numeric' 23 | }); 24 | 25 | return; 26 | } else { 27 | const lines = await service.getLines(req.query.year, req.query.seasonType, req.query.week, req.query.team, req.query.home, req.query.away, req.query.conference); 28 | res.send(lines); 29 | } 30 | } catch (err) { 31 | Sentry.captureException(err); 32 | res.status(500).send({ 33 | error: 'Something went wrong.' 34 | }); 35 | } 36 | }; 37 | 38 | return { 39 | getLines 40 | }; 41 | }; -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | 30 | 31 | 32 | @BlueSCar 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: "@sentry/node" 10 | versions: 11 | - 6.0.2 12 | - 6.0.3 13 | - 6.0.4 14 | - 6.1.0 15 | - 6.2.0 16 | - 6.2.1 17 | - 6.2.2 18 | - 6.2.3 19 | - 6.2.4 20 | - 6.3.0 21 | - dependency-name: pg 22 | versions: 23 | - 8.5.1 24 | - dependency-name: swagger-ui-dist 25 | versions: 26 | - 3.40.0 27 | - 3.41.1 28 | - 3.42.0 29 | - 3.43.0 30 | - 3.44.0 31 | - 3.44.1 32 | - 3.45.0 33 | - 3.45.1 34 | - 3.46.0 35 | - dependency-name: helmet 36 | versions: 37 | - 4.4.1 38 | - dependency-name: swagger-stats 39 | versions: 40 | - 0.95.18 41 | - 0.95.19 42 | - dependency-name: mocha 43 | versions: 44 | - 8.2.1 45 | - 8.3.0 46 | - 8.3.1 47 | - dependency-name: chai 48 | versions: 49 | - 4.3.0 50 | - 4.3.1 51 | - 4.3.3 52 | - dependency-name: pg-promise 53 | versions: 54 | - 10.9.1 55 | - 10.9.2 56 | - 10.9.4 57 | - dependency-name: knex 58 | versions: 59 | - 0.21.16 60 | - 0.21.17 61 | - dependency-name: objection 62 | versions: 63 | - 2.2.12 64 | - 2.2.14 65 | - dependency-name: postgraphile 66 | versions: 67 | - 4.10.0 68 | - 4.11.0 69 | - dependency-name: express-slow-down 70 | versions: 71 | - 1.4.0 72 | -------------------------------------------------------------------------------- /app/coach/coach.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./coach.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | return { 7 | getCoaches: async (req, res) => { 8 | try { 9 | if (req.query.year && isNaN(req.query.year)) { 10 | res.status(400).send({ 11 | error: 'Year param must be numeric' 12 | }); 13 | 14 | return; 15 | } else if (req.query.minYear && isNaN(req.query.minYear)) { 16 | res.status(400).send({ 17 | error: 'minYear param must be numeric.' 18 | }); 19 | 20 | return; 21 | } else if (req.query.maxYear && isNaN(req.query.maxYear)) { 22 | res.status(400).send({ 23 | error: 'maxYear param must be numeric.' 24 | }); 25 | 26 | return; 27 | } else { 28 | const coaches = await service.getCoaches(req.query.firstName, req.query.lastName, req.query.team, req.query.year, req.query.minYear, req.query.maxYear); 29 | res.send(coaches); 30 | } 31 | } catch (err) { 32 | Sentry.captureException(err); 33 | res.status(500).send({ 34 | error: 'Something went wrong.' 35 | }); 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app/auth/auth.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./auth.service'); 2 | 3 | module.exports = (Sentry, db) => { 4 | const service = serviceConstructor(db); 5 | const emailPattern = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; 6 | 7 | const generateKey = async (req, res) => { 8 | try { 9 | if (!req.body && !req.body.email) { 10 | res.status(400).send({ 11 | error: 'An email address is required' 12 | }); 13 | } else if (!emailPattern.test(req.body.email)) { 14 | res.status(400).send({ 15 | error: 'A valid email address is required' 16 | }); 17 | } else { 18 | await service.generateKey(req.body.email); 19 | res.sendStatus(200); 20 | } 21 | } catch (err) { 22 | Sentry.captureException(err); 23 | res.status(500).send('Something went wrong'); 24 | } 25 | }; 26 | 27 | const graphQLAuth = async (req, res) => { 28 | try { 29 | res.status(200).send({ 30 | "X-Hasura-User-Id": `${req.user.id}`, 31 | "X-Hasura-Role": "user", 32 | "X-Hasura-Is-Owner": "false", 33 | "Cache-Control": "max-age=86400" 34 | }); 35 | } catch (err) { 36 | res.sendStatus(401); 37 | } 38 | }; 39 | 40 | return { 41 | generateKey, 42 | graphQLAuth 43 | }; 44 | }; -------------------------------------------------------------------------------- /app/draft/draft.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./draft.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | const getTeams = async (req, res) => { 7 | try { 8 | const teams = await service.getTeams(); 9 | res.send(teams); 10 | } catch (err) { 11 | Sentry.captureException(err); 12 | res.status(500).send({ 13 | error: 'Something went wrong.' 14 | }); 15 | } 16 | }; 17 | 18 | const getPositions = async (req, res) => { 19 | try { 20 | let positions = await service.getPositions(); 21 | res.send(positions); 22 | } catch (err) { 23 | Sentry.captureException(err); 24 | res.status(500).send({ 25 | error: 'Something went wrong.' 26 | }); 27 | } 28 | }; 29 | 30 | const getPicks = async (req, res) => { 31 | try { 32 | if (req.query.year && isNaN(req.query.year)) { 33 | res.status(400).send({ 34 | error: 'Year param must be numeric' 35 | }); 36 | } else { 37 | let picks = await service.getPicks(req.query.year, req.query.nflTeam, req.query.college, req.query.conference, req.query.position); 38 | res.send(picks); 39 | } 40 | } catch (err) { 41 | Sentry.captureException(err); 42 | res.status(500).send({ 43 | error: 'Something went wrong.' 44 | }); 45 | } 46 | }; 47 | 48 | return { 49 | getTeams, 50 | getPositions, 51 | getPicks 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to the CFBD API 2 | 3 | #### **Did you find a bug?** 4 | 5 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cfbd/cfb-api/issues). 6 | 7 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cfbd/cfb-api/issues/new?assignees=bluescar). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 8 | 9 | #### **Did you write a patch that fixes a bug?** 10 | 11 | * Open a new GitHub pull request with the patch. 12 | 13 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 14 | 15 | #### **Did you fix whitespace, format code, or make a purely cosmetic patch?** 16 | 17 | Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of the CFBD API will generally not be accepted. 18 | 19 | #### **Do you intend to add a new feature or change an existing one?** 20 | 21 | * Suggest your change by reaching out to me via [email](mailto:admin@collegefootballdata.com) or [Twitter](https://twitter.com/CFB_Data) and start writing code. 22 | 23 | * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. 24 | 25 | #### **Do you have questions about the source code?** 26 | 27 | * Please do reach out to me via [email](mailto:admin@collegefootballdata.com) or [Twitter](https://twitter.com/CFB_Data) with any questions you may have. 28 | 29 | Thanks! 30 | 31 | BlueSCar 32 | -------------------------------------------------------------------------------- /config/slowdown.js: -------------------------------------------------------------------------------- 1 | const slowDown = require('express-slow-down'); 2 | 3 | module.exports = () => { 4 | const speedLimiter = slowDown({ 5 | windowMs: 10 * 1000, 6 | delayAfter: 10, 7 | delayMs: 750, 8 | keyGenerator: (req) => { 9 | return req.user.username; 10 | } 11 | }); 12 | 13 | const speedLimiterTier1 = slowDown({ 14 | windowMs: 10 * 1000, 15 | delayAfter: 15, 16 | delayMs: 500, 17 | keyGenerator: (req) => { 18 | return req.user.username; 19 | } 20 | }); 21 | 22 | const speedLimiterTier2 = slowDown({ 23 | windowMs: 10 * 1000, 24 | delayAfter: 20, 25 | delayMs: 400, 26 | keyGenerator: (req) => { 27 | return req.user.username; 28 | } 29 | }); 30 | 31 | const speedLimiterLive = slowDown({ 32 | windowMs: 10 * 1000, 33 | delayAfter: 10, 34 | delayMs: 500, 35 | keyGenerator: (req) => { 36 | return req.user.username; 37 | } 38 | }); 39 | 40 | const speedLimiterHeavy = slowDown({ 41 | windowMs: 10 * 1000, 42 | delayAfter: 5, 43 | delayMs: 1000, 44 | keyGenerator: (req) => { 45 | return `${req.user.username}_heavy`; 46 | } 47 | }); 48 | 49 | const limitedEndpoints = ['/stats/game/advanced', '/game/box/advanced', '/metrics/wp/pregame']; 50 | 51 | const limiter = (req, res, next) => { 52 | let tier = req.user ? req.user.patronLevel : null; 53 | 54 | if (req.sws.api_path == '/live/plays') { 55 | speedLimiterLive(req, res, next); 56 | } else if (req.user.throttled && limitedEndpoints.includes(req.sws.api_path)) { 57 | speedLimiterHeavy(req, res, next); 58 | } else if (tier == 2) { 59 | speedLimiterTier2(req, res, next); 60 | } else if (tier == 1) { 61 | speedLimiterTier1(req, res, next); 62 | } else { 63 | speedLimiter(req, res, next); 64 | } 65 | } 66 | 67 | return limiter; 68 | } -------------------------------------------------------------------------------- /test/coach.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const coaches = require('../app/coach/coach.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Coaches', () => { 8 | it('should retrieve coach data by first name', async () => { 9 | const data = await coaches.getCoaches('Nick'); 10 | 11 | data.should.be.an("array"); 12 | data.length.should.be.gt(0); 13 | Array.from(new Set(data.map(d => d.first_name))).length.should.equal(1); 14 | }); 15 | 16 | it('should retrieve coach data by last name', async () => { 17 | const data = await coaches.getCoaches(null, 'Smith'); 18 | 19 | data.should.be.an("array"); 20 | data.length.should.be.gt(0); 21 | Array.from(new Set(data.map(d => d.last_name))).length.should.equal(1); 22 | }); 23 | 24 | it('should retrieve coach data by team', async () => { 25 | const data = await coaches.getCoaches(null, null, 'Ohio State'); 26 | 27 | data.should.be.an("array"); 28 | data.length.should.be.gt(0); 29 | for (let coach of data) { 30 | for (let season of coach.seasons) { 31 | season.school.should.equal('Ohio State'); 32 | } 33 | } 34 | }); 35 | 36 | it ('should retrieve coach data for a single season', async () => { 37 | const data = await coaches.getCoaches(null, null, null, 2019); 38 | 39 | data.should.be.an("array"); 40 | data.length.should.be.gt(0); 41 | for (let coach of data) { 42 | for (let season of coach.seasons) { 43 | season.year.should.equal(2019); 44 | } 45 | } 46 | }); 47 | 48 | it ('should retrieve coach data between a range of years', async () => { 49 | const data = await coaches.getCoaches(null, null, null, null, 2015, 2018); 50 | 51 | data.should.be.an("array"); 52 | data.length.should.be.gt(0); 53 | for (let coach of data) { 54 | for (let season of coach.seasons) { 55 | season.year.should.be.gte(2015); 56 | season.year.should.be.lte(2018); 57 | } 58 | } 59 | }); 60 | }) -------------------------------------------------------------------------------- /app/events/events.route.js: -------------------------------------------------------------------------------- 1 | module.exports = async (app, consumers, expressWs) => { 2 | 3 | app.ws('/events/games', (ws, req) => { 4 | ws.options = { 5 | team: req.query.team ? req.query.team.toLowerCase() : null, 6 | events: [] 7 | }; 8 | 9 | if (req.query.gameStarted !== false) { 10 | ws.options.events.push('game_started'); 11 | } 12 | 13 | if (req.query.gameCompleted !== false) { 14 | ws.options.events.push('game_completed'); 15 | } 16 | 17 | if (req.query.quarterStarted !== false) { 18 | ws.options.events.push('quarter_started'); 19 | } 20 | 21 | if (req.query.halftimeStarted !== false) { 22 | ws.options.events.push('halftime_started'); 23 | } 24 | 25 | if (req.query.scoreChanged !== false) { 26 | ws.options.events.push('score_changed'); 27 | } 28 | }); 29 | 30 | const gamesWs = expressWs.getWss('/events/games'); 31 | const broadcastGameEvent = (eventType) => { 32 | return (info) => { 33 | gamesWs.clients.forEach((client) => { 34 | if (client.options && client.options.team) { 35 | if (info.homeTeam.location.toLowerCase() !== client.options.team && info.awayTeam.location.toLowerCase() !== client.options.team) { 36 | return; 37 | } 38 | } 39 | 40 | if (client.options && !client.options.events.includes(eventType)) { 41 | return; 42 | } 43 | 44 | client.send(JSON.stringify({ 45 | eventType, 46 | info 47 | }, null, '\t')); 48 | }); 49 | } 50 | }; 51 | 52 | await consumers.createQueue('game_started', broadcastGameEvent('game_started')); 53 | await consumers.createQueue('game_completed', broadcastGameEvent('game_completed')); 54 | await consumers.createQueue('quarter_started', broadcastGameEvent('quarter_started')); 55 | await consumers.createQueue('halftime_started', broadcastGameEvent('halftime_started')); 56 | await consumers.createQueue('score_changed', broadcastGameEvent('score_changed')); 57 | } -------------------------------------------------------------------------------- /test/draft.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const draft = require('../app/draft/draft.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Drafts', () => { 8 | it('should retrieve a list of nfl teams', async () => { 9 | const data = await draft.getTeams(); 10 | 11 | data.should.be.an("array"); 12 | data.length.should.be.gt(0); 13 | }); 14 | 15 | it('should retrieve a list of nfl positions', async () => { 16 | const data = await draft.getPositions(); 17 | 18 | data.should.be.an("array"); 19 | data.length.should.be.gt(0); 20 | }); 21 | 22 | it('should retrieve a list picks in the year 2020', async () => { 23 | const data = await draft.getPicks(2020); 24 | 25 | data.should.be.an("array"); 26 | data.length.should.be.gt(0); 27 | for (let pick of data) { 28 | pick.year.should.equal(2020); 29 | } 30 | }); 31 | 32 | it('should retrieve a list picks in the year 2020 from alabama', async () => { 33 | const data = await draft.getPicks(2020, null, 'Alabama'); 34 | 35 | data.should.be.an("array"); 36 | data.length.should.be.gt(0); 37 | for (let pick of data) { 38 | pick.year.should.equal(2020); 39 | pick.collegeTeam.should.equal('Alabama'); 40 | } 41 | }); 42 | 43 | it('should retrieve a list picks in the year 2020 for Detroit', async () => { 44 | const data = await draft.getPicks(2020, 'Detroit'); 45 | 46 | data.should.be.an("array"); 47 | data.length.should.be.gt(0); 48 | for (let pick of data) { 49 | pick.year.should.equal(2020); 50 | pick.nflTeam.should.equal('Detroit'); 51 | } 52 | }); 53 | 54 | it('should retrieve a list picks in the year 2020 from the big ten', async () => { 55 | const data = await draft.getPicks(2020, null, null, 'B1G'); 56 | 57 | data.should.be.an("array"); 58 | data.length.should.be.gt(0); 59 | for (let pick of data) { 60 | pick.year.should.equal(2020); 61 | pick.collegeConference.should.equal('Big Ten'); 62 | } 63 | }); 64 | 65 | it('should retrieve a list of running back picks in the year 2020', async () => { 66 | const data = await draft.getPicks(2020, null, null, null, 'RB'); 67 | 68 | data.should.be.an("array"); 69 | data.length.should.be.gt(0); 70 | for (let pick of data) { 71 | pick.year.should.equal(2020); 72 | pick.position.should.equal('Running Back'); 73 | } 74 | }); 75 | }) -------------------------------------------------------------------------------- /test/lines.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const lines = require('../app/lines/lines.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | const assert = chai.assert; 7 | 8 | describe('Betting Lines', () => { 9 | it('Should retrieve betting lines for a given game id', async () => { 10 | const data = await lines.getLines(401117500); 11 | 12 | data.should.be.an('array'); 13 | data.length.should.be.gt(0); 14 | }); 15 | 16 | it('Should retrieve betting lines for a given season', async () => { 17 | const data = await lines.getLines(null, 2019); 18 | 19 | data.should.be.an('array'); 20 | data.length.should.be.gt(0); 21 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 22 | }); 23 | 24 | it('Should retrieve betting lines for a given season and week', async () => { 25 | const data = await lines.getLines(null, 2019, null, 5); 26 | 27 | data.should.be.an('array'); 28 | data.length.should.be.gt(0); 29 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 30 | Array.from(new Set(data.map(l => l.week))).length.should.equal(1); 31 | }); 32 | 33 | it('Should retrieve betting lines for a given season and team', async () => { 34 | const data = await lines.getLines(null, 2019, null, null, 'Nebraska'); 35 | 36 | data.should.be.an('array'); 37 | data.length.should.be.gt(0); 38 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 39 | 40 | for (let line of data) { 41 | assert(line.homeTeam === 'Nebraska' || line.awayTeam === 'Nebraska', 'Team parameter not working.') 42 | } 43 | }); 44 | 45 | it('Should retrieve betting lines for a given season and home team', async () => { 46 | const data = await lines.getLines(null, 2019, null, null, null, 'Nebraska'); 47 | 48 | data.should.be.an('array'); 49 | data.length.should.be.gt(0); 50 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 51 | Array.from(new Set(data.map(l => l.homeTeam))).length.should.equal(1); 52 | }); 53 | 54 | it('Should retrieve betting lines for a given season and away team', async () => { 55 | const data = await lines.getLines(null, 2019, null, null, null, null, 'Nebraska'); 56 | 57 | data.should.be.an('array'); 58 | data.length.should.be.gt(0); 59 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 60 | Array.from(new Set(data.map(l => l.awayTeam))).length.should.equal(1); 61 | }); 62 | 63 | it('Should retrieve betting lines for a given season and conference', async () => { 64 | const data = await lines.getLines(null, 2019, null, null, null, null, null, 'MAC'); 65 | 66 | data.should.be.an('array'); 67 | data.length.should.be.gt(0); 68 | Array.from(new Set(data.map(l => l.season))).length.should.equal(1); 69 | 70 | for (let line of data) { 71 | assert(line.homeConference === 'Mid-American' || line.awayConference === 'Mid-American', 'Conference parameter not working.') 72 | } 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/stats.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const stats = require('../app/stats/stats.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Team Stats', () => { 8 | describe('Categories', () => { 9 | it('Should get a list of team statistical categories', async () => { 10 | const categories = await stats.getCategories(); 11 | 12 | categories.should.be.an('array'); 13 | categories.length.should.be.gt(0); 14 | }); 15 | }); 16 | 17 | 18 | describe('Season', () => { 19 | it('Should retrieve team stats from a single conference', async () => { 20 | const data = await stats.getTeamStats(2019, null, 'B1G'); 21 | 22 | data.should.be.an('array'); 23 | data.length.should.be.gt(0); 24 | Array.from(new Set(data.map(s => s.conference))).length.should.equal(1); 25 | }); 26 | 27 | it('Should retrieve stats from a subset of weeks for a single team and season', async () => { 28 | const data = await stats.getTeamStats(2019, 'Michigan', null, 4, 10); 29 | 30 | data.should.be.an('array'); 31 | data.length.should.be.gt(0); 32 | Array.from(new Set(data.map(s => s.team))).length.should.equal(1); 33 | Array.from(new Set(data.map(s => s.season))).length.should.equal(1); 34 | }); 35 | }); 36 | 37 | describe('Advanced Season', () => { 38 | it('Should retrieve stats from a subset of weeks for a single team and season', async () => { 39 | const data = await stats.getAdvancedStats(2019, 'Texas', true, 4, 10); 40 | 41 | data.should.be.an('array'); 42 | data.length.should.be.gt(0); 43 | Array.from(new Set(data.map(s => s.team))).length.should.equal(1); 44 | Array.from(new Set(data.map(s => s.season))).length.should.equal(1); 45 | }); 46 | }); 47 | 48 | describe('Advanced Game', () => { 49 | it('Should retrieve team stats with garbage time excluded for a single team and season', async () => { 50 | const data = await stats.getAdvancedGameStats(2019, 'Texas', null, null, true); 51 | 52 | data.should.be.an('array'); 53 | data.length.should.be.gt(0); 54 | Array.from(new Set(data.map(s => s.conference))).length.should.equal(1); 55 | Array.from(new Set(data.map(s => s.team))).length.should.equal(1); 56 | Array.from(new Set(data.map(s => s.season))).length.should.equal(1); 57 | }); 58 | 59 | it('Should retrieve team stats from a single week', async () => { 60 | const data = await stats.getAdvancedGameStats(2019, null, 4); 61 | 62 | data.should.be.an('array'); 63 | data.length.should.be.gt(0); 64 | Array.from(new Set(data.map(s => s.week))).length.should.equal(1); 65 | }); 66 | 67 | it('Should retrieve team stats from a single opponent', async () => { 68 | const data = await stats.getAdvancedGameStats(2019, null, null, 'USC'); 69 | 70 | data.should.be.an('array'); 71 | data.length.should.be.gt(0); 72 | Array.from(new Set(data.map(s => s.opponent))).length.should.equal(1); 73 | }); 74 | }); 75 | 76 | describe('Advanced Box Score', () => { 77 | it('Should retrieve box score data for a specific game', async () => { 78 | const data = await stats.getAdvancedBoxScore(4035409); 79 | 80 | data.should.be.an('object'); 81 | }) 82 | }); 83 | }); -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at admin@collegefootballdata.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /app/coach/coach.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getCoaches = async (firstName, lastName, team, year, minYear, maxYear) => { 3 | let filter = ''; 4 | let params = []; 5 | let index = 1; 6 | 7 | if (firstName) { 8 | filter += `${index == 1 ? 'WHERE' : ' AND'} LOWER(c.first_name) = LOWER($${index})`; 9 | params.push(firstName); 10 | index++; 11 | } 12 | 13 | if (lastName) { 14 | filter += `${index == 1 ? 'WHERE' : ' AND'} LOWER(c.last_name) = LOWER($${index})`; 15 | params.push(lastName); 16 | index++; 17 | } 18 | 19 | if (team) { 20 | filter += `${index == 1 ? 'WHERE' : ' AND'} LOWER(t.school) = LOWER($${index})`; 21 | params.push(team); 22 | index++; 23 | } 24 | 25 | if (year) { 26 | filter += `${index == 1 ? 'WHERE' : ' AND'} cs.year = $${index}`; 27 | params.push(year); 28 | index++; 29 | } 30 | 31 | if (minYear) { 32 | filter += `${index == 1 ? 'WHERE' : ' AND'} cs.year >= $${index}`; 33 | params.push(minYear); 34 | index++; 35 | } 36 | 37 | 38 | if (maxYear) { 39 | filter += `${index == 1 ? 'WHERE' : ' AND'} cs.year <= $${index}`; 40 | params.push(maxYear); 41 | index++; 42 | } 43 | 44 | let results = await db.any(` 45 | SELECT c.id, 46 | c.first_name, 47 | c.last_name, 48 | t.school, 49 | ct.hire_date, 50 | cs.year, 51 | cs.games, 52 | cs.wins, 53 | cs.losses, 54 | cs.ties, 55 | cs.preseason_rank, 56 | cs.postseason_rank, 57 | ROUND(srs.rating, 1) AS srs, 58 | r.rating AS sp, 59 | r.o_rating AS sp_offense, 60 | r.d_rating AS sp_defense 61 | FROM coach c 62 | INNER JOIN coach_season cs ON c.id = cs.coach_id 63 | INNER JOIN team t ON cs.team_id = t.id 64 | LEFT JOIN srs ON cs.year = srs.year AND t.id = srs.team_id 65 | LEFT JOIN ratings AS r ON r.year = srs.year AND r.team_id = srs.team_id 66 | LEFT JOIN coach_team AS ct ON ct.coach_id = c.id AND ct.team_id = t.id AND cs.year >= EXTRACT(year FROM ct.hire_date) 67 | ${filter} 68 | ORDER BY c.last_name, c.first_name, cs.year 69 | `, params); 70 | 71 | let coaches = []; 72 | let ids = Array.from(new Set(results.map(r => r.id))); 73 | for (let id of ids) { 74 | let coachSeasons = results.filter(r => r.id == id); 75 | 76 | coaches.push({ 77 | first_name: coachSeasons[0].first_name, 78 | last_name: coachSeasons[0].last_name, 79 | hire_date: coachSeasons[0].hire_date, 80 | seasons: coachSeasons.map(cs => { 81 | return { 82 | school: cs.school, 83 | year: cs.year, 84 | games: cs.games, 85 | wins: cs.wins, 86 | losses: cs.losses, 87 | ties: cs.ties, 88 | preseason_rank: cs.preseason_rank, 89 | postseason_rank: cs.postseason_rank, 90 | srs: cs.srs, 91 | sp_overall: cs.sp, 92 | sp_offense: cs.sp_offense, 93 | sp_defense: cs.sp_defense 94 | } 95 | }) 96 | }); 97 | } 98 | 99 | return coaches; 100 | }; 101 | 102 | return { 103 | getCoaches 104 | }; 105 | }; 106 | -------------------------------------------------------------------------------- /app/ratings/ratings.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./ratings.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | const getSP = async (req, res) => { 7 | try { 8 | if (!req.query.year && !req.query.team) { 9 | res.status(400).send('A year or team must be specified.'); 10 | } else if (req.query.year && !parseInt(req.query.year)) { 11 | res.status(400).send('Year must be an integer.'); 12 | } else { 13 | let ratings = await service.getSP(req.query.year, req.query.team); 14 | res.send(ratings); 15 | } 16 | } catch (err) { 17 | Sentry.captureException(err); 18 | res.status(500).send({ 19 | error: 'Something went wrong.' 20 | }); 21 | } 22 | }; 23 | 24 | const getConferenceSP = async (req, res) => { 25 | try { 26 | if (req.query.year && !parseInt(req.query.year)) { 27 | res.status(400).send('Year must be an integer'); 28 | } else { 29 | let ratings = await service.getConferenceSP(req.query.year, req.query.conference); 30 | res.send(ratings); 31 | } 32 | } catch (err) { 33 | Sentry.captureException(err); 34 | res.status(500).send({ 35 | error: 'Something went wrong.' 36 | }); 37 | } 38 | }; 39 | 40 | const getSRS = async (req, res) => { 41 | try { 42 | if (!req.query.year && !req.query.team) { 43 | res.status(400).send('A year or team must be specified.'); 44 | } else if (req.query.year && !parseInt(req.query.year)) { 45 | res.status(400).send('Year must be an integer.'); 46 | } else { 47 | let ratings = await service.getSRS(req.query.year, req.query.team, req.query.conference); 48 | res.send(ratings); 49 | } 50 | } catch (err) { 51 | Sentry.captureException(err); 52 | res.status(500).send({ 53 | error: 'Something went wrong.' 54 | }); 55 | } 56 | }; 57 | 58 | const getElo = async (req, res) => { 59 | try { 60 | if (req.query.year && !parseInt(req.query.year)) { 61 | res.status(400).send('Year must be an integer.'); 62 | } else if (req.query.week && !parseInt(req.query.week)) { 63 | res.status(400).send('Week must be an integer.'); 64 | } else { 65 | let elos = await service.getElo(req.query.year, req.query.week, req.query.seasonType, req.query.team, req.query.conference); 66 | res.send(elos); 67 | } 68 | } catch (err) { 69 | Sentry.captureException(err); 70 | res.status(500).send({ 71 | error: 'Something went wrong.' 72 | }); 73 | } 74 | }; 75 | 76 | const getFpi = async (req, res) => { 77 | try { 78 | if (!req.query.year && !req.query.team) { 79 | res.status(400).send("Year or team must be specified"); 80 | } else if (req.query.year && !parseInt(req.query.year)) { 81 | res.status(400).send('Year must be an integer.'); 82 | } else { 83 | let elos = await service.getFpi(req.query.year, req.query.team, req.query.conference); 84 | res.send(elos); 85 | } 86 | } catch (err) { 87 | Sentry.captureException(err); 88 | res.status(500).send({ 89 | error: 'Something went wrong.' 90 | }); 91 | } 92 | } 93 | 94 | return { 95 | getSP, 96 | getConferenceSP, 97 | getSRS, 98 | getElo, 99 | getFpi 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /app/play/play.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./play.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | const getPlayTypes = async (req, res) => { 7 | try { 8 | let types = await service.getPlayTypes(); 9 | res.send(types); 10 | } catch (err) { 11 | Sentry.captureException(err); 12 | res.status(500).send({ 13 | error: 'Something went wrong.' 14 | }); 15 | } 16 | }; 17 | 18 | const getPlayStatTypes = async (req, res) => { 19 | try { 20 | let types = await service.getPlayStatTypes(); 21 | res.send(types); 22 | } catch (err) { 23 | Sentry.captureException(err); 24 | res.status(500).send({ 25 | error: 'Something went wrong.' 26 | }); 27 | } 28 | }; 29 | 30 | const getPlays = async (req, res) => { 31 | try { 32 | if (!req.query.year || isNaN(req.query.year)) { 33 | res.status(400).send({ 34 | error: 'A numeric year parameter must be specified.' 35 | }); 36 | } else if (!req.query.week || isNaN(req.query.week)) { 37 | res.status(400).send({ 38 | error: 'A numeric week parameter must be specified.' 39 | }); 40 | } else if (req.query.seasonType && req.query.seasonType != 'regular' && req.query.seasonType != 'postseason' && req.query.seasonType != 'both') { 41 | res.status(400).send({ 42 | error: 'Invalid season type' 43 | }); 44 | } else { 45 | let plays = await service.getPlays( 46 | req.query.year, 47 | req.query.week, 48 | req.query.team, 49 | req.query.offense, 50 | req.query.defense, 51 | req.query.offenseConference, 52 | req.query.defenseConference, 53 | req.query.conference, 54 | req.query.playType, 55 | req.query.seasonType, 56 | req.query.classification 57 | ); 58 | 59 | res.send(plays); 60 | } 61 | } catch (err) { 62 | Sentry.captureException(err); 63 | res.status(500).send({ 64 | error: 'Something went wrong.' 65 | }); 66 | } 67 | }; 68 | 69 | const getPlayStats = async (req, res) => { 70 | try { 71 | if (req.query.year && !parseInt(req.query.year)) { 72 | res.status(400).send({ 73 | error: 'Year parameter must be numeric' 74 | }); 75 | } else if (req.query.week && !parseInt(req.query.week)) { 76 | res.status(400).send({ 77 | error: 'Week parameter must be numeric' 78 | }); 79 | } else if (req.query.statTypeId && !parseInt(req.query.statTypeId)) { 80 | res.status(400).send({ 81 | error: 'statTypeId parameter must be numeric' 82 | }); 83 | } else { 84 | let stats = await service.getPlayStats( 85 | req.query.year, 86 | req.query.week, 87 | req.query.team, 88 | req.query.gameId, 89 | req.query.athleteId, 90 | req.query.statTypeId, 91 | req.query.seasonType, 92 | req.query.conference 93 | ); 94 | 95 | res.send(stats); 96 | } 97 | } catch (err) { 98 | Sentry.captureException(err); 99 | res.status(500).send({ 100 | error: 'Something went wrong.' 101 | }); 102 | } 103 | } 104 | 105 | return { 106 | getPlays, 107 | getPlayTypes, 108 | getPlayStatTypes, 109 | getPlayStats 110 | } 111 | } -------------------------------------------------------------------------------- /app/draft/draft.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getTeams = async () => { 3 | let teams = await db.any(`SELECT * FROM draft_team ORDER BY location`); 4 | 5 | return teams.map(t => ({ 6 | location: t.location, 7 | nickname: t.mascot, 8 | displayName: t.display_name, 9 | logo: t.logo 10 | })); 11 | }; 12 | 13 | const getPositions = async () => { 14 | let positions = await db.any(`SELECT DISTINCT name, abbreviation FROM draft_position ORDER BY name`); 15 | 16 | return positions; 17 | }; 18 | 19 | const getPicks = async (year, team, school, conference, position) => { 20 | const filters = []; 21 | const params = []; 22 | let index = 1; 23 | 24 | if (year) { 25 | filters.push(`dp.year = $${index}`); 26 | params.push(year); 27 | index++; 28 | } 29 | 30 | if (team) { 31 | filters.push(`LOWER(dt.location) = LOWER($${index})`); 32 | params.push(team); 33 | index++; 34 | } 35 | 36 | if (school) { 37 | filters.push(`LOWER(ct.school) = LOWER($${index})`); 38 | params.push(school); 39 | index++; 40 | } 41 | 42 | if (conference) { 43 | filters.push(`LOWER(c.abbreviation) = LOWER($${index})`); 44 | params.push(conference); 45 | index++; 46 | } 47 | 48 | if (position) { 49 | filters.push(`(LOWER(pos.name) = LOWER($${index}) OR LOWER(pos.abbreviation) = LOWER($${index}))`); 50 | params.push(position); 51 | index++; 52 | } 53 | 54 | const filter = filters.length ? 'WHERE ' + filters.join(' AND ') : ''; 55 | 56 | let picks = await db.any(` 57 | SELECT dp.college_id AS college_athlete_id, 58 | dp.id AS nfl_athlete_id, 59 | ct.id AS college_id, 60 | ct.school AS college_team, 61 | c.name AS conference, 62 | dt.location AS nfl_team, 63 | dp.year, 64 | dp.overall, 65 | dp.round, 66 | dp.pick, 67 | dp.name, 68 | pos.name AS "position", 69 | dp.height, 70 | dp.weight, 71 | dp.overall_rank, 72 | dp.position_rank, 73 | dp.grade, 74 | h.city, 75 | h.state, 76 | h.country, 77 | h.latitude, 78 | h.longitude, 79 | h.county_fips 80 | FROM draft_picks AS dp 81 | INNER JOIN draft_team AS dt ON dp.nfl_team_id = dt.id 82 | INNER JOIN draft_position AS pos ON dp.position_id = pos.id 83 | INNER JOIN team AS ct ON dp.college_team_id = ct.id 84 | LEFT JOIN conference_team AS cot ON ct.id = cot.team_id AND (dp.year - 1) >= cot.start_year AND (cot.end_year IS NULL OR (dp.year - 1) <= cot.end_year) 85 | LEFT JOIN conference AS c ON cot.conference_id = c.id 86 | LEFT JOIN athlete AS a ON dp.college_id = a.id 87 | LEFT JOIN hometown AS h ON a.hometown_id = h.id 88 | ${filter} 89 | ORDER BY overall 90 | `, params); 91 | 92 | return picks.map(p => ({ 93 | collegeAthleteId: p.college_athlete_id, 94 | nflAthleteId: p.nfl_athlete_id, 95 | collegeId: p.college_id, 96 | collegeTeam: p.college_team, 97 | collegeConference: p.conference, 98 | nflTeamId: p.nfl_team_id, 99 | nflTeam: p.nfl_team, 100 | year: p.year, 101 | overall: p.overall, 102 | round: p.round, 103 | pick: p.pick, 104 | name: p.name, 105 | position: p.position, 106 | height: p.height, 107 | weight: p.weight, 108 | preDraftRanking: p.overall_rank, 109 | preDraftPositionRanking: p.position_rank, 110 | preDraftGrade: p.grade, 111 | hometownInfo: { 112 | city: p.city, 113 | state: p.state, 114 | country: p.country, 115 | latitude: p.latitude, 116 | longitude: p.longitude, 117 | countyFips: p.county_fips 118 | } 119 | })); 120 | }; 121 | 122 | return { 123 | getTeams, 124 | getPicks, 125 | getPositions 126 | }; 127 | }; 128 | -------------------------------------------------------------------------------- /app/lines/lines.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getLines = async (year, seasonType, week, team, home, away, conference) => { 3 | let filter = 'WHERE g.season = $1'; 4 | let params = [year]; 5 | 6 | let index = 2; 7 | 8 | if (seasonType != 'both') { 9 | filter += ` AND g.season_type = $${index}`; 10 | params.push(seasonType || 'regular'); 11 | index++; 12 | } 13 | 14 | if (week) { 15 | filter += ` AND g.week = $${index}`; 16 | params.push(week); 17 | index++; 18 | } 19 | 20 | if (team) { 21 | filter += ` AND (LOWER(awt.school) = LOWER($${index}) OR LOWER(ht.school) = LOWER($${index}))`; 22 | params.push(team); 23 | index++; 24 | } 25 | 26 | if (home) { 27 | filter += ` AND LOWER(ht.school) = LOWER($${index})`; 28 | params.push(home); 29 | index++; 30 | } 31 | 32 | if (away) { 33 | filter += ` AND LOWER(awt.school) = LOWER($${index})`; 34 | params.push(away); 35 | index++; 36 | } 37 | 38 | if (conference) { 39 | filter += ` AND (LOWER(hc.abbreviation) = LOWER($${index}) OR LOWER(ac.abbreviation) = LOWER($${index}))`; 40 | params.push(conference); 41 | index++; 42 | } 43 | 44 | let games = await db.any(` 45 | SELECT g.id, g.season, g.week, g.season_type, g.start_date, ht.school AS home_team, hc.name AS home_conference, hgt.points AS home_score, awt.school AS away_team, ac.name AS away_conference, agt.points AS away_score 46 | FROM game AS g 47 | INNER JOIN game_team AS hgt ON hgt.game_id = g.id AND hgt.home_away = 'home' 48 | INNER JOIN team AS ht ON hgt.team_id = ht.id 49 | LEFT JOIN conference_team hct ON ht.id = hct.team_id AND hct.start_year <= g.season AND (hct.end_year >= g.season OR hct.end_year IS NULL) 50 | LEFT JOIN conference hc ON hct.conference_id = hc.id 51 | INNER JOIN game_team AS agt ON agt.game_id = g.id AND agt.home_away = 'away' 52 | INNER JOIN team AS awt ON agt.team_id = awt.id 53 | LEFT JOIN conference_team act ON awt.id = act.team_id AND act.start_year <= g.season AND (act.end_year >= g.season OR act.end_year IS NULL) 54 | LEFT JOIN conference ac ON act.conference_id = ac.id 55 | ${filter} 56 | `, params); 57 | 58 | let gameIds = games.map(g => g.id); 59 | 60 | if (!gameIds.length) { 61 | return []; 62 | } 63 | 64 | let lines = await db.any(` 65 | SELECT g.id, p.name, gl.spread, gl.spread_open, gl.over_under, gl.over_under_open, gl.moneyline_home, gl.moneyline_away 66 | FROM game AS g 67 | INNER JOIN game_lines AS gl ON g.id = gl.game_id 68 | INNER JOIN lines_provider AS p ON gl.lines_provider_id = p.id 69 | WHERE g.id IN ($1:list) 70 | `, [gameIds]); 71 | 72 | let results = games.map(g => { 73 | let gameLines = lines 74 | .filter(l => l.id == g.id) 75 | .map(l => ({ 76 | provider: l.name, 77 | spread: l.spread, 78 | formattedSpread: l.spread < 0 ? `${g.home_team} ${l.spread}` : `${g.away_team} -${l.spread}`, 79 | spreadOpen: l.spread_open, 80 | overUnder: l.over_under, 81 | overUnderOpen: l.over_under_open, 82 | homeMoneyline: l.moneyline_home, 83 | awayMoneyline: l.moneyline_away 84 | })); 85 | 86 | return { 87 | id: g.id, 88 | season: g.season, 89 | seasonType: g.season_type, 90 | week: g.week, 91 | startDate: g.start_date, 92 | homeTeam: g.home_team, 93 | homeConference: g.home_conference, 94 | homeScore: g.home_score, 95 | awayTeam: g.away_team, 96 | awayConference: g.away_conference, 97 | awayScore: g.away_score, 98 | lines: gameLines 99 | }; 100 | }); 101 | 102 | return results; 103 | }; 104 | 105 | return { 106 | getLines 107 | }; 108 | }; 109 | -------------------------------------------------------------------------------- /test/ratings.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const ratings = require('../app/ratings/ratings.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Ratings', () => { 8 | describe('Team SP', () => { 9 | it('it should get SP+ ratings by year', async () => { 10 | const data = await ratings.getSP(2019, null); 11 | 12 | data.should.be.an('array'); 13 | data.length.should.be.gt(0); 14 | Array.from(new Set(data.map(d => d.year))).length.should.equal(1); 15 | Array.from(new Set(data.filter(d => d.team !== 'nationalAverages').map(d => d.team))).length.should.be.gt(1); 16 | }); 17 | 18 | it('it should get SP+ ratings by team', async () => { 19 | const data = await ratings.getSP(null, 'Michigan'); 20 | 21 | data.should.be.an('array'); 22 | data.length.should.be.gt(0); 23 | Array.from(new Set(data.map(d => d.year))).length.should.be.gt(1); 24 | Array.from(new Set(data.filter(d => d.team !== 'nationalAverages').map(d => d.team))).length.should.equal(1); 25 | }); 26 | 27 | it('it should get SP+ ratings by year and team', async () => { 28 | const data = await ratings.getSP(2019, 'Michigan'); 29 | 30 | data.should.be.an('array'); 31 | data.filter(d => d.team !== 'nationalAverages').length.should.equal(1); 32 | }); 33 | }); 34 | 35 | describe('Conference SP', () => { 36 | it('it should get SP+ ratings', async () => { 37 | const data = await ratings.getConferenceSP(null, null); 38 | 39 | data.should.be.an('array'); 40 | data.length.should.be.gt(0); 41 | Array.from(new Set(data.map(d => d.year))).length.should.be.gt(1); 42 | Array.from(new Set(data.map(d => d.conference))).length.should.be.gt(1); 43 | }); 44 | 45 | it('it should get SP+ ratings by year', async () => { 46 | const data = await ratings.getConferenceSP(2019, null); 47 | 48 | data.should.be.an('array'); 49 | data.length.should.be.gt(0); 50 | Array.from(new Set(data.map(d => d.year))).length.should.equal(1); 51 | Array.from(new Set(data.map(d => d.conference))).length.should.be.gt(1); 52 | }); 53 | 54 | it('it should get SP+ ratings by conference', async () => { 55 | const data = await ratings.getConferenceSP(null, 'B1G'); 56 | 57 | data.should.be.an('array'); 58 | data.length.should.be.gt(0); 59 | Array.from(new Set(data.map(d => d.year))).length.should.be.gt(1); 60 | Array.from(new Set(data.map(d => d.conference))).length.should.equal(1); 61 | }); 62 | 63 | it('it should get SP+ ratings by conference and year', async () => { 64 | const data = await ratings.getConferenceSP(2019, 'B1G'); 65 | 66 | data.should.be.an('array'); 67 | data.length.should.equal(1); 68 | }); 69 | }); 70 | 71 | describe('SRS', () => { 72 | it('it should get SRS ratings by year', async () => { 73 | const data = await ratings.getSRS(2019, null, null); 74 | 75 | data.should.be.an('array'); 76 | data.length.should.be.gt(0); 77 | Array.from(new Set(data.map(d => d.year))).length.should.equal(1); 78 | Array.from(new Set(data.map(d => d.team))).length.should.be.gt(1); 79 | Array.from(new Set(data.map(d => d.conference))).length.should.be.gt(1); 80 | }); 81 | 82 | it('it should get SRS ratings by team', async () => { 83 | const data = await ratings.getSRS(null, 'michigan', null); 84 | 85 | data.should.be.an('array'); 86 | data.length.should.be.gt(0); 87 | Array.from(new Set(data.map(d => d.team))).length.should.equal(1); 88 | Array.from(new Set(data.map(d => d.year))).length.should.be.gt(1); 89 | Array.from(new Set(data.map(d => d.conference))).length.should.be.gt(1); 90 | }); 91 | 92 | it('it should get SRS ratings by conference', async () => { 93 | const data = await ratings.getSRS(null, null, 'B1G'); 94 | 95 | data.should.be.an('array'); 96 | data.length.should.be.gt(0); 97 | Array.from(new Set(data.map(d => d.conference))).length.should.equal(1); 98 | Array.from(new Set(data.map(d => d.team))).length.should.be.gt(1); 99 | Array.from(new Set(data.map(d => d.year))).length.should.be.gt(1); 100 | }); 101 | }); 102 | }); -------------------------------------------------------------------------------- /app/rankings/rankings.controller.js: -------------------------------------------------------------------------------- 1 | module.exports = (db, Sentry) => { 2 | return { 3 | getRankings: async (req, res) => { 4 | try { 5 | if (!req.query.year || isNaN(req.query.year)) { 6 | res.status(400).send({ 7 | error: 'A numeric year parameter must be specified.' 8 | }); 9 | 10 | return; 11 | } 12 | 13 | let filter = 'WHERE p.season = $1'; 14 | let params = [req.query.year]; 15 | 16 | let index = 2; 17 | 18 | if (req.query.seasonType != 'both') { 19 | if (req.query.seasonType && req.query.seasonType != 'regular' && req.query.seasonType != 'postseason' && req.query.seasonType != 'both') { 20 | res.status(400).send({ 21 | error: 'Invalid season type' 22 | }); 23 | 24 | return; 25 | } 26 | 27 | filter += ` AND p.season_type = $${index}`; 28 | params.push(req.query.seasonType || 'regular'); 29 | index++; 30 | } 31 | 32 | if (req.query.week) { 33 | if (isNaN(req.query.week)) { 34 | res.status(400).send({ 35 | error: 'Week parameter must be numeric' 36 | }); 37 | 38 | return; 39 | } 40 | 41 | filter += ` AND p.week = $${index}`; 42 | params.push(req.query.week); 43 | index++; 44 | } 45 | 46 | let data = await db.any(` select p.season_type, p.season, p.week, pt.name as poll, pr.rank, t.school, c.name as conference, pr.first_place_votes, pr.points 47 | from poll_type pt 48 | inner join poll p on pt.id = p.poll_type_id 49 | inner join poll_rank pr on p.id = pr.poll_id 50 | inner join team t on pr.team_id = t.id 51 | left join conference_team ct on t.id = ct.team_id AND ct.start_year <= p.season AND (ct.end_year >= p.season OR ct.end_year IS NULL) 52 | left join conference c on ct.conference_id = c.id 53 | ${filter}`, params); 54 | 55 | 56 | let results = []; 57 | 58 | let seasons = Array.from(new Set(data.map(d => d.season))); 59 | for (let season of seasons) { 60 | let seasonTypes = Array.from(new Set(data.filter(d => d.season == season).map(d => d.season_type))); 61 | for (let seasonType of seasonTypes) { 62 | let weeks = Array.from(new Set(data.filter(d => d.season == season && d.season_type == seasonType).map(d => d.week))); 63 | for (let week of weeks) { 64 | let weekRecord = { 65 | season, 66 | seasonType, 67 | week, 68 | polls: [] 69 | }; 70 | 71 | let records = data.filter(d => d.season == season && d.season_type == seasonType && d.week == week).map(d => d); 72 | let polls = Array.from(new Set(records.map(r => r.poll))); 73 | 74 | for (let poll of polls) { 75 | weekRecord.polls.push({ 76 | poll, 77 | ranks: records.filter(r => r.poll == poll).map(r => { 78 | return { 79 | rank: r.rank, 80 | school: r.school, 81 | conference: r.conference, 82 | firstPlaceVotes: r.first_place_votes, 83 | points: r.points 84 | } 85 | }) 86 | }); 87 | } 88 | 89 | results.push(weekRecord); 90 | } 91 | } 92 | } 93 | 94 | res.send(results); 95 | } catch (err) { 96 | Sentry.captureException(err); 97 | res.status(500).send({ 98 | error: 'Something went wrong.' 99 | }); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/stats/stats.controller.js: -------------------------------------------------------------------------------- 1 | const serviceContructor = require('./stats.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceContructor(db); 5 | 6 | const getTeamStats = async (req, res) => { 7 | try { 8 | if (!req.query.year && !req.query.team) { 9 | res.status(400).send({ 10 | error: 'year or team are required' 11 | }); 12 | } else if (req.query.year && !parseInt(req.query.year)) { 13 | res.status(400).send({ 14 | error: 'year must be numeric' 15 | }); 16 | } else if (req.query.startWeek && !parseInt(req.query.startWeek)) { 17 | res.status(400).send({ 18 | error: 'startWeek must be numeric' 19 | }); 20 | } else if (req.query.endWeek && !parseInt(req.query.endWeek)) { 21 | res.status(400).send({ 22 | error: 'endWeek must be numeric' 23 | }); 24 | } else { 25 | const stats = await service.getTeamStats(req.query.year, req.query.team, req.query.conference, req.query.startWeek, req.query.endWeek); 26 | res.send(stats); 27 | } 28 | } catch (err) { 29 | Sentry.captureException(err); 30 | res.status(500).send({ 31 | error: 'Something went wrong.' 32 | }); 33 | } 34 | } 35 | 36 | const getCategories = async (req, res) => { 37 | const categories = await service.getCategories(); 38 | res.send(categories); 39 | } 40 | 41 | const getAdvancedStats = async (req, res) => { 42 | try { 43 | if (!req.query.year && !req.query.team) { 44 | res.status(400).send({ 45 | error: 'team or year must be specified' 46 | }); 47 | } else if (req.query.year && !parseInt(req.query.year)) { 48 | res.status(400).send({ 49 | error: 'year must be numeric' 50 | }); 51 | } else if (req.query.startWeek && !parseInt(req.query.startWeek)) { 52 | res.status(400).send({ 53 | error: 'startWeek must be numeric' 54 | }); 55 | } else if (req.query.endWeek && !parseInt(req.query.endWeek)) { 56 | res.status(400).send({ 57 | error: 'endWeek must be numeric' 58 | }); 59 | } else { 60 | const results = await service.getAdvancedStats(req.query.year, req.query.team, req.query.excludeGarbageTime, req.query.startWeek, req.query.endWeek); 61 | res.send(results); 62 | } 63 | } catch (err) { 64 | Sentry.captureException(err); 65 | res.status(500).send({ 66 | error: 'something went wrong' 67 | }); 68 | } 69 | }; 70 | 71 | const getAdvancedGameStats = async (req, res) => { 72 | try { 73 | if (!req.query.year && !req.query.team) { 74 | res.status(400).send({ 75 | error: 'team or year must be specified' 76 | }); 77 | } else if (req.query.year && !parseInt(req.query.year)) { 78 | res.status(400).send({ 79 | error: 'year must be numeric' 80 | }); 81 | } else if (req.query.week && !parseInt(req.query.week)) { 82 | res.status(400).send({ 83 | error: 'week must be numeric' 84 | }); 85 | } else if (req.query.seasonType && req.query.seasonType.toLowerCase() != 'both' && req.query.seasonType.toLowerCase() != 'regular' && req.query.seasonType.toLowerCase() != 'postseason') { 86 | res.status(400).send({ 87 | error: 'invalid seasonType' 88 | }); 89 | } else { 90 | const results = await service.getAdvancedGameStats(req.query.year, req.query.team, req.query.week, req.query.opponent, req.query.excludeGarbageTime, req.query.seasonType); 91 | res.send(results); 92 | } 93 | } catch (err) { 94 | Sentry.captureException(err); 95 | res.status(500).send({ 96 | error: 'something went wrong' 97 | }); 98 | } 99 | }; 100 | 101 | const getAdvancedBoxScore = async (req, res) => { 102 | try { 103 | if (!req.query.gameId) { 104 | res.status(400).send({ 105 | error: 'gameId must be specified' 106 | }); 107 | } else if (!parseInt(req.query.gameId)) { 108 | res.status(400).send({ 109 | error: 'gameId must be numeric' 110 | }); 111 | } else { 112 | const result = await service.getAdvancedBoxScore(req.query.gameId); 113 | res.send(result); 114 | } 115 | } catch (err) { 116 | Sentry.captureException(err); 117 | res.status(500).send({ 118 | error: 'something went wrong' 119 | }); 120 | } 121 | } 122 | 123 | return { 124 | getTeamStats, 125 | getCategories, 126 | getAdvancedStats, 127 | getAdvancedGameStats, 128 | getAdvancedBoxScore 129 | } 130 | } -------------------------------------------------------------------------------- /test/players.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const players = require('../app/player/player.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | 7 | describe('Players', () => { 8 | describe('Search', () => { 9 | it('it should search for players named "Smith"', async () => { 10 | const data = await players.playerSearch(null, null, null, 'Smith'); 11 | 12 | data.should.be.an('array'); 13 | data.length.should.be.gt(0); 14 | }); 15 | 16 | it('it should search for inactive QBs named "Smith"', async () => { 17 | const data = await players.playerSearch(2014, null, 'QB', 'Smith'); 18 | 19 | data.should.be.an('array'); 20 | data.length.should.be.gt(0); 21 | Array.from(new Set(data.map(d => d.position))).length.should.equal(1); 22 | }); 23 | }); 24 | 25 | describe('Passing Charts', () => { 26 | it('it should get a passing chart for the specified player (no rolling specified)', async () => { 27 | const data = await players.getMeanPassingChartData(4035409, null, 2020); 28 | 29 | data.should.be.an('array'); 30 | data.length.should.be.gt(0); 31 | }); 32 | 33 | it('it should get a passing chart for the specified player (10 rolling plays)', async () => { 34 | const data = await players.getMeanPassingChartData(4035409, 10, 2020); 35 | 36 | data.should.be.an('array'); 37 | data.length.should.be.gt(0); 38 | }); 39 | }); 40 | 41 | describe('Player Usage', () => { 42 | it('it should get player usage chart data', async () => { 43 | const data = await players.getPlayerUsage(2019, 'B1G', 'QB', null, null, 'true'); 44 | 45 | data.should.be.an('array'); 46 | data.length.should.be.gt(0); 47 | }); 48 | 49 | it('it should get player usage chart data by team', async () => { 50 | const data = await players.getPlayerUsage(2019, null, null, 'Michigan', null, 'false'); 51 | 52 | data.should.be.an('array'); 53 | data.length.should.be.gt(0); 54 | }); 55 | 56 | it('it should get player usage chart data for a specific player', async () => { 57 | const data = await players.getPlayerUsage(2019, null, null, null, 4035409, null); 58 | 59 | data.should.be.an('array'); 60 | data.length.should.be.gt(0); 61 | }); 62 | }); 63 | 64 | describe('Returning Production', () => { 65 | it('it should get returning production data for the given year', async () => { 66 | const data = await players.getReturningProduction(2019, null, null); 67 | 68 | data.should.be.an('array'); 69 | data.length.should.be.gt(0); 70 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 71 | }); 72 | 73 | it('it should get returning production data for the given team', async () => { 74 | const data = await players.getReturningProduction(null, 'michigan', null); 75 | 76 | data.should.be.an('array'); 77 | data.length.should.be.gt(0); 78 | Array.from(new Set(data.map(d => d.team))).length.should.equal(1); 79 | }); 80 | 81 | it('it should get returning production data for the given conference', async () => { 82 | const data = await players.getReturningProduction(null, null, 'SEC'); 83 | 84 | data.should.be.an('array'); 85 | data.length.should.be.gt(0); 86 | Array.from(new Set(data.map(d => d.conference))).length.should.equal(1); 87 | }); 88 | }); 89 | 90 | describe('Season Stats', () => { 91 | it('it should get player stats for a given season and conference', async () => { 92 | const data = await players.getSeasonStats(2019, 'B1G'); 93 | 94 | data.should.be.an('array'); 95 | data.length.should.be.gt(0); 96 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 97 | Array.from(new Set(data.map(d => d.conference))).length.should.equal(1); 98 | }); 99 | 100 | it('it should get player stats for a given season and team', async () => { 101 | const data = await players.getSeasonStats(2019, null, 'Akron'); 102 | 103 | data.should.be.an('array'); 104 | data.length.should.be.gt(0); 105 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 106 | Array.from(new Set(data.map(d => d.team))).length.should.equal(1); 107 | }); 108 | 109 | it('it should get player stats for a given season and category', async () => { 110 | const data = await players.getSeasonStats(2019, null, null, null, null, null, 'kicking'); 111 | 112 | data.should.be.an('array'); 113 | data.length.should.be.gt(0); 114 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 115 | Array.from(new Set(data.map(d => d.category))).length.should.equal(1); 116 | }); 117 | 118 | it('it should get player stats for a given season and range of weeks', async () => { 119 | const data = await players.getSeasonStats(2019, null, null, 4, 8); 120 | 121 | data.should.be.an('array'); 122 | data.length.should.be.gt(0); 123 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 124 | }); 125 | }); 126 | }); -------------------------------------------------------------------------------- /config/express.js: -------------------------------------------------------------------------------- 1 | module.exports = async (Sentry) => { 2 | const express = require("express"); 3 | // const expressWs = require('express-ws'); 4 | 5 | const helmet = require("helmet"); 6 | const bodyParser = require("body-parser"); 7 | const session = require("cookie-session"); 8 | const cookieParser = require("cookie-parser"); 9 | const cors = require("cors"); 10 | const swStats = require("swagger-stats"); 11 | 12 | const passport = require("passport"); 13 | const passportConfig = require("./passport"); 14 | const apmConfig = require("./apm"); 15 | 16 | const env = process.env.NODE_ENV; 17 | const corsOrigin = process.env.CORS_ORIGIN; 18 | 19 | const brute = require("./brute")(); 20 | 21 | let corsOptions; 22 | 23 | if (env != "development") { 24 | corsOptions = { 25 | origin: (origin, cb) => { 26 | if (!origin || origin == corsOrigin) { 27 | cb(null, true); 28 | } else { 29 | cb(new Error(`Not allowed by CORS: ${origin}`)); 30 | } 31 | }, 32 | }; 33 | } else { 34 | corsOptions = {}; 35 | } 36 | 37 | let corsConfig = cors(corsOptions); 38 | 39 | const app = express(); 40 | // const expressWsObj = expressWs(app); 41 | 42 | app.enable("trust proxy"); 43 | 44 | app.use(Sentry.Handlers.requestHandler()); 45 | 46 | app.use( 47 | helmet({ 48 | contentSecurityPolicy: false, 49 | }) 50 | ); 51 | app.use( 52 | session({ 53 | name: "session", 54 | secret: process.env.SESSION_SECRET, 55 | maxAge: 7 * 24 * 60 * 60 * 1000, 56 | secureProxy: true, 57 | }) 58 | ); 59 | app.use(cookieParser()); 60 | app.use(bodyParser.json()); 61 | app.use( 62 | bodyParser.urlencoded({ 63 | extended: true, 64 | }) 65 | ); 66 | 67 | const dbInfo = require("./database")(); 68 | passportConfig(passport, dbInfo.authDb); 69 | const apm = apmConfig(dbInfo.authDb); 70 | 71 | const originAuth = (req, res, next) => { 72 | passport.authenticate("bearer", (err, user, info) => { 73 | if (user && user.blacklisted == true) { 74 | res.status(401).send("Account has been blacklisted."); 75 | } else if ( 76 | user || 77 | (req.get("origin") == corsOrigin || req.get("host") == corsOrigin) || 78 | env == "development" 79 | ) { 80 | req.user = user; 81 | next(); 82 | } else { 83 | res 84 | .status(401) 85 | .send( 86 | 'Unauthorized. Did you forget to add "Bearer " before your key? Go to CollegeFootballData.com to register for your free API key. See the CFBD Blog for examples on usage: https://blog.collegefootballdata.com/using-api-keys-with-the-cfbd-api.' 87 | ); 88 | } 89 | })(req, res, next); 90 | }; 91 | 92 | const patreonAuth = (req, res, next) => { 93 | passport.authenticate("bearer", (err, user, info) => { 94 | if ( 95 | (user && user.patronLevel && user.patronLevel > 0) || 96 | env == "development" 97 | ) { 98 | req.user = user; 99 | next(); 100 | } else { 101 | res 102 | .status(401) 103 | .send( 104 | "This endpoint is in limited beta and requires a Patreon subscription." 105 | ); 106 | } 107 | })(req, res, next); 108 | }; 109 | 110 | const superPatreonAuth = (req, res, next) => { 111 | passport.authenticate("bearer", (err, user, info) => { 112 | if ( 113 | (user && user.patronLevel && user.patronLevel > 1) || 114 | env == "development" 115 | ) { 116 | req.user = user; 117 | next(); 118 | } else { 119 | res 120 | .status(401) 121 | .send( 122 | "This endpoint is in limited beta and requires a Patreon Tier 2 subscription." 123 | ); 124 | } 125 | })(req, res, next); 126 | }; 127 | 128 | require("./swagger")(app, cors); 129 | app.use( 130 | "/api/docs", 131 | cors(), 132 | express.static("./node_modules/swagger-ui-dist") 133 | ); 134 | 135 | app.use( 136 | swStats.getMiddleware({ 137 | swaggerSpec: require("../swagger"), 138 | apdexThreshold: 250, 139 | authentication: true, 140 | onAuthenticate: (req, username, password) => { 141 | return ( 142 | username.toLowerCase() == process.env.USERNAME.toLowerCase() && 143 | password == process.env.PASSWORD 144 | ); 145 | }, 146 | }) 147 | ); 148 | 149 | const limiter = require("./slowdown")(); 150 | 151 | const middlewares = [corsConfig, originAuth, apm, limiter]; 152 | const patreonMiddlewares = [corsConfig, patreonAuth, apm, limiter]; 153 | 154 | require("../app/auth/auth.route")(app, corsConfig, Sentry, superPatreonAuth); 155 | require("../app/coach/coach.route")(app, dbInfo.db, middlewares, Sentry); 156 | require("../app/game/game.route")( 157 | app, 158 | dbInfo.db, 159 | middlewares, 160 | Sentry, 161 | patreonMiddlewares 162 | ); 163 | require("../app/play/play.route")(app, dbInfo.db, middlewares, Sentry); 164 | require("../app/team/team.route")(app, dbInfo.db, middlewares, Sentry); 165 | require("../app/venue/venue.route")(app, dbInfo.db, middlewares, Sentry); 166 | require("../app/rankings/rankings.route")( 167 | app, 168 | dbInfo.db, 169 | middlewares, 170 | Sentry 171 | ); 172 | require("../app/lines/lines.route")(app, dbInfo.db, middlewares, Sentry); 173 | require("../app/recruiting/recruiting.route")( 174 | app, 175 | dbInfo.db, 176 | middlewares, 177 | Sentry 178 | ); 179 | require("../app/ratings/ratings.route")(app, dbInfo.db, middlewares, Sentry); 180 | require("../app/ppa/ppa.routes")(app, dbInfo.db, middlewares, Sentry); 181 | require("../app/stats/stats.routes")(app, dbInfo.db, middlewares, Sentry); 182 | require("../app/player/player.routes")(app, dbInfo.db, middlewares, Sentry); 183 | require("../app/draft/draft.route")(app, dbInfo.db, middlewares, Sentry); 184 | await require("../app/live/live.route")( 185 | app, 186 | dbInfo.db, 187 | patreonMiddlewares, 188 | Sentry 189 | ); 190 | 191 | // const consumers = await require('./consumers')(); 192 | // await require('../app/events/events.route')(app, consumers, expressWsObj); 193 | 194 | app.get("*", (req, res) => { 195 | res.redirect("/api/docs/?url=/api-docs.json"); 196 | }); 197 | 198 | // app.use(Sentry.Handlers.errorHandler()); 199 | 200 | return app; 201 | }; 202 | -------------------------------------------------------------------------------- /app/player/player.controller.js: -------------------------------------------------------------------------------- 1 | const playerService = require('./player.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = playerService(db); 5 | 6 | const playerSearch = async (req, res) => { 7 | try { 8 | if (!req.query.searchTerm) { 9 | res.status(400).send({ 10 | error: 'searchTerm must be specified' 11 | }); 12 | } else if (req.query.year && !parseInt(req.query.year)) { 13 | res.status(400).send({ 14 | error: 'year must be an integer' 15 | }); 16 | }else { 17 | let results = await service.playerSearch(req.query.year, req.query.team, req.query.position, req.query.searchTerm); 18 | res.send(results); 19 | } 20 | } catch (err) { 21 | Sentry.captureException(err); 22 | res.status(500).send({ 23 | error: 'Something went wrong.' 24 | }); 25 | } 26 | }; 27 | 28 | const getMeanPassingPPA = async (req, res) => { 29 | try { 30 | if (!req.query.id || !parseInt(req.query.id)) { 31 | res.status(400).send({ 32 | error: 'a numeric id param is required' 33 | }); 34 | } else if (req.query.rollingPlays && !parseInt(req.query.rollingPlays)) { 35 | res.status(400).send({ 36 | error: 'rollingPlays must be numeric' 37 | }); 38 | } else if (req.query.year && !parseInt(req.query.year)) { 39 | res.status(400).send({ 40 | error: 'year must be numeric' 41 | }); 42 | } else { 43 | let results = await service.getMeanPassingChartData(req.query.id, req.query.rollingPlays, req.query.year); 44 | res.send(results); 45 | } 46 | } catch (err) { 47 | Sentry.captureException(err); 48 | res.status(500).send({ 49 | error: 'Something went wrong.' 50 | }); 51 | } 52 | }; 53 | 54 | const getPlayerUsage = async (req, res) => { 55 | try { 56 | if (!req.query.year) { 57 | res.status(400).send({ 58 | error: 'year must be specified' 59 | }); 60 | } else if (!parseInt(req.query.year)) { 61 | res.status(400).send({ 62 | error: 'year must be numeric' 63 | }); 64 | } else if (req.query.playerId && !parseInt(req.query.playerId)) { 65 | res.status(400).send({ 66 | error: 'playerId must be numeric' 67 | }); 68 | } else { 69 | const results = await service.getPlayerUsage(req.query.year, req.query.conference, req.query.position, req.query.team, req.query.playerId, req.query.excludeGarbageTime); 70 | res.send(results); 71 | } 72 | } catch (err) { 73 | Sentry.captureException(err); 74 | res.status(500).send({ 75 | error: 'Something went wrong.' 76 | }); 77 | } 78 | }; 79 | 80 | const getReturningProduction = async (req, res) => { 81 | try { 82 | if (!req.query.year && !req.query.team) { 83 | res.status(400).send({ 84 | error: 'year or team must be specified' 85 | }); 86 | } else if (req.query.year && !parseInt(req.query.year)) { 87 | res.status(400).send({ 88 | error: 'year must be numeric' 89 | }); 90 | } else { 91 | const results = await service.getReturningProduction(req.query.year, req.query.team, req.query.conference); 92 | res.send(results); 93 | } 94 | } catch (err) { 95 | Sentry.captureException(err); 96 | res.status(500).send({ 97 | error: 'Something went wrong.' 98 | }); 99 | } 100 | }; 101 | 102 | const getSeasonStats = async (req, res) => { 103 | try { 104 | if (!req.query.year) { 105 | res.status(400).send({ 106 | error: 'year must be specified' 107 | }); 108 | } else if (!parseInt(req.query.year)) { 109 | res.status(400).send({ 110 | error: 'year must be an integer' 111 | }); 112 | } else if (req.query.startWeek && !parseInt(req.query.startWeek)) { 113 | res.status(400).send({ 114 | error: 'startWeek must be an integer' 115 | }); 116 | } else if (req.query.endWeek && !parseInt(req.query.endWeek)) { 117 | res.status(400).send({ 118 | error: 'endWeek must be an integer' 119 | }); 120 | } else { 121 | const data = await service.getSeasonStats(req.query.year, req.query.conference, req.query.team, req.query.startWeek, req.query.endWeek, req.query.seasonType, req.query.category); 122 | 123 | res.send(data); 124 | } 125 | } catch (err) { 126 | Sentry.captureException(err); 127 | res.status(500).send({ 128 | error: 'Something went wrong.' 129 | }); 130 | } 131 | }; 132 | 133 | const getTransferPortal = async (req, res) => { 134 | try { 135 | if (!req.query.year) { 136 | res.status(400).send({ 137 | error: 'year must be specified' 138 | }); 139 | } else if (!parseInt(req.query.year)) { 140 | res.status(400).send({ 141 | error: 'year must be an integer' 142 | }); 143 | } else { 144 | const data = await service.getTransferPortal(req.query.year); 145 | res.send(data); 146 | } 147 | } catch (err) { 148 | Sentry.captureException(err); 149 | res.status(500).send({ 150 | error: 'Something went wrong.' 151 | }); 152 | } 153 | }; 154 | 155 | return { 156 | playerSearch, 157 | getMeanPassingPPA, 158 | getPlayerUsage, 159 | getReturningProduction, 160 | getSeasonStats, 161 | getTransferPortal 162 | }; 163 | }; 164 | -------------------------------------------------------------------------------- /test/games.js: -------------------------------------------------------------------------------- 1 | const db = require('./config').db; 2 | const service = require('../app/game/game.service')(db); 3 | 4 | const chai = require('chai'); 5 | const should = chai.should(); 6 | const assert = chai.assert; 7 | 8 | describe('Games', () => { 9 | describe('Media', () => { 10 | it('Should retrieve game media for the given year', async () => { 11 | const data = await service.getMedia(2019); 12 | 13 | data.should.be.an('array'); 14 | data.length.should.be.gt(0); 15 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 16 | }); 17 | 18 | 19 | it('Should retrieve game media for the given year and week', async () => { 20 | const data = await service.getMedia(2019, null, 5); 21 | 22 | data.should.be.an('array'); 23 | data.length.should.be.gt(0); 24 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 25 | Array.from(new Set(data.map(d => d.week))).length.should.equal(1); 26 | }); 27 | 28 | it('Should retrieve game media for the given year and team', async () => { 29 | const data = await service.getMedia(2019, null, null, 'UCLA'); 30 | 31 | data.should.be.an('array'); 32 | data.length.should.be.gt(0); 33 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 34 | 35 | for (let game of data) { 36 | assert(game.homeTeam === 'UCLA' || game.awayTeam === 'UCLA', 'Team parameter not working') 37 | } 38 | }); 39 | 40 | it('Should retrieve game media for the given year and conference', async () => { 41 | const data = await service.getMedia(2019, null, null, null, 'ACC'); 42 | 43 | data.should.be.an('array'); 44 | data.length.should.be.gt(0); 45 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 46 | 47 | for (let game of data) { 48 | assert(game.homeConference === 'ACC' || game.awayConference === 'ACC', 'Conference parameter not working'); 49 | } 50 | }); 51 | 52 | 53 | it('Should retrieve game media for the given year and media type', async () => { 54 | const data = await service.getMedia(2019, null, null, null, null, 'web'); 55 | 56 | data.should.be.an('array'); 57 | data.length.should.be.gt(0); 58 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 59 | Array.from(new Set(data.map(d => d.mediaType))).length.should.equal(1); 60 | }); 61 | }); 62 | 63 | // describe('Weather', () => { 64 | // it('Should retrieve game weather for the given year', async () => { 65 | // const data = await service.getWeather(2020); 66 | 67 | // data.should.be.an('array'); 68 | // data.length.should.be.gt(0); 69 | // Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 70 | // }); 71 | 72 | 73 | // it('Should retrieve game weather for the given year and week', async () => { 74 | // const data = await service.getWeather(2020, null, 5); 75 | 76 | // data.should.be.an('array'); 77 | // data.length.should.be.gt(0); 78 | // Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 79 | // Array.from(new Set(data.map(d => d.week))).length.should.equal(1); 80 | // }); 81 | 82 | // it('Should retrieve game weather for the given year and team', async () => { 83 | // const data = await service.getWeather(2020, null, null, 'UCLA'); 84 | 85 | // data.should.be.an('array'); 86 | // data.length.should.be.gt(0); 87 | // Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 88 | 89 | // for (let game of data) { 90 | // assert(game.homeTeam === 'UCLA' || game.awayTeam === 'UCLA', 'Team parameter not working') 91 | // } 92 | // }); 93 | 94 | // it('Should retrieve game weather for the given year and conference', async () => { 95 | // const data = await service.getWeather(2020, null, null, null, 'ACC'); 96 | 97 | // data.should.be.an('array'); 98 | // data.length.should.be.gt(0); 99 | // Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 100 | 101 | // for (let game of data) { 102 | // assert(game.homeConference === 'ACC' || game.awayConference === 'ACC', 'Conference parameter not working'); 103 | // } 104 | // }); 105 | // }); 106 | }); 107 | 108 | describe('Drives', () => { 109 | it('Should retrieve drive data for the given year', async () => { 110 | const data = await service.getDrives(2019); 111 | 112 | data.should.be.an('array'); 113 | data.length.should.be.gt(0); 114 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 115 | }); 116 | 117 | it('Should retrieve drive data for the given year and week', async () => { 118 | const data = await service.getDrives(2019, null, 6); 119 | 120 | data.should.be.an('array'); 121 | data.length.should.be.gt(0); 122 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 123 | Array.from(new Set(data.map(d => d.week))).length.should.equal(1); 124 | }); 125 | 126 | it('Should retrieve drive data for the given year and team', async () => { 127 | const data = await service.getDrives(2019, null, null, 'Houston'); 128 | 129 | data.should.be.an('array'); 130 | data.length.should.be.gt(0); 131 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 132 | 133 | for (let drive of data) { 134 | assert(drive.offense === 'Houston' || drive.defense === 'Houston', 'Team parameter not working'); 135 | } 136 | }); 137 | 138 | it('Should retrieve drive data for the given year and offense', async () => { 139 | const data = await service.getDrives(2019, null, null, null, 'Houston'); 140 | 141 | data.should.be.an('array'); 142 | data.length.should.be.gt(0); 143 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 144 | Array.from(new Set(data.map(d => d.offense))).length.should.equal(1); 145 | }); 146 | 147 | it('Should retrieve drive data for the given year and defense', async () => { 148 | const data = await service.getDrives(2019, null, null, null, null, 'Houston'); 149 | 150 | data.should.be.an('array'); 151 | data.length.should.be.gt(0); 152 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 153 | Array.from(new Set(data.map(d => d.defense))).length.should.equal(1); 154 | }); 155 | 156 | it('Should retrieve drive data for the given year and offensive conference', async () => { 157 | const data = await service.getDrives(2019, null, null, null, null, null, 'B1G'); 158 | 159 | data.should.be.an('array'); 160 | data.length.should.be.gt(0); 161 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 162 | Array.from(new Set(data.map(d => d.offense_conference))).length.should.equal(1); 163 | }); 164 | 165 | it('Should retrieve drive data for the given year and defensive conference', async () => { 166 | const data = await service.getDrives(2019, null, null, null, null, null, null, 'B1G'); 167 | 168 | data.should.be.an('array'); 169 | data.length.should.be.gt(0); 170 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 171 | Array.from(new Set(data.map(d => d.defense_conference))).length.should.equal(1); 172 | }); 173 | 174 | it('Should retrieve drive data for the given year and conference', async () => { 175 | const data = await service.getDrives(2019, null, null, null, null, null, null, null, 'B1G'); 176 | 177 | data.should.be.an('array'); 178 | data.length.should.be.gt(0); 179 | Array.from(new Set(data.map(d => d.season))).length.should.equal(1); 180 | 181 | for (let drive of data) { 182 | assert(drive.offense_conference === 'Big Ten' || drive.defense_conference === 'Big Ten', 'Conference parameter not working'); 183 | } 184 | }); 185 | }) -------------------------------------------------------------------------------- /app/ppa/ppa.controller.js: -------------------------------------------------------------------------------- 1 | const serviceConstructor = require('./ppa.service'); 2 | 3 | module.exports = (db, Sentry) => { 4 | const service = serviceConstructor(db); 5 | 6 | const getPP = async (req, res) => { 7 | try { 8 | if (!req.query.down || !req.query.distance) { 9 | res.status(400).send({ 10 | error: 'Down and distance must be specified.' 11 | }); 12 | } else if (!parseInt(req.query.down)) { 13 | res.status(400).send({ 14 | error: 'Down must be numeric.' 15 | }); 16 | } else if (!parseInt(req.query.distance)) { 17 | res.status(400).send({ 18 | error: 'Distance must be numeric' 19 | }); 20 | } else { 21 | let results = await service.getPP(req.query.down, req.query.distance); 22 | res.send(results); 23 | } 24 | } catch (err) { 25 | Sentry.captureException(err); 26 | res.status(500).send({ 27 | error: 'Something went wrong.' 28 | }); 29 | } 30 | }; 31 | 32 | const getWP = async (req, res) => { 33 | try { 34 | if (!req.query.gameId) { 35 | res.status(400).send({ 36 | error: 'gameId is required.' 37 | }); 38 | } else { 39 | let results = await service.getWP(req.query.gameId, req.query.adjustForSpread); 40 | res.send(results); 41 | } 42 | } catch (err) { 43 | Sentry.captureException(err); 44 | res.status(500).send({ 45 | error: 'Something went wrong.' 46 | }); 47 | } 48 | } 49 | 50 | const getPPAByTeam = async (req, res) => { 51 | try { 52 | if (!req.query.year && !req.query.team) { 53 | res.status(400).send({ 54 | error: 'year or team are required' 55 | }); 56 | } else if (req.query.year && !parseInt(req.query.year)) { 57 | res.status(400).send({ 58 | error: 'year must be numeric' 59 | }); 60 | } else { 61 | const results = await service.getPPAByTeam(req.query.year, req.query.team, req.query.conference, req.query.excludeGarbageTime); 62 | res.send(results); 63 | } 64 | } catch (err) { 65 | Sentry.captureException(err); 66 | res.status(500).send({ 67 | error: 'Something went wrong.' 68 | }); 69 | } 70 | } 71 | 72 | const getPPAByGame = async (req, res) => { 73 | try { 74 | if (!req.query.year) { 75 | res.status(400).send({ 76 | error: 'year must be specified' 77 | }); 78 | } else if (req.query.year && !parseInt(req.query.year)) { 79 | res.status(400).send({ 80 | error: 'year must be numeric' 81 | }); 82 | } else if (req.query.week && !parseInt(req.query.week)) { 83 | res.status(400).send({ 84 | error: 'week must be numeric' 85 | }); 86 | } else if (req.query.seasonType && req.query.seasonType != 'regular' && req.query.seasonType != 'postseason' && req.query.seasonType != 'both') { 87 | res.status(400).send({ 88 | error: 'Invalid season type' 89 | }); 90 | } else { 91 | const results = await service.getPPAByGame(req.query.year, req.query.team, req.query.conference, req.query.week, req.query.excludeGarbageTime, req.query.seasonType); 92 | res.send(results); 93 | } 94 | } catch (err) { 95 | Sentry.captureException(err); 96 | res.status(500).send({ 97 | error: 'Something went wrong.' 98 | }); 99 | } 100 | }; 101 | 102 | const getPPAByPlayerGame = async (req, res) => { 103 | try { 104 | if (!req.query.week && !req.query.team) { 105 | res.status(400).send({ 106 | error: 'A week or team must be specified' 107 | }); 108 | } else if (req.query.year && !parseInt(req.query.year)) { 109 | res.status(400).send({ 110 | error: 'year must be numeric' 111 | }); 112 | } else if (req.query.week && !parseInt(req.query.week)) { 113 | res.status(400).send({ 114 | error: 'week must be numeric' 115 | }); 116 | } else if (req.query.threshold && !parseInt(req.query.threshold)) { 117 | res.status(400).send({ 118 | error: 'threshold must by numeric' 119 | }); 120 | } else if (req.query.playerId && !parseInt(req.query.playerId)) { 121 | res.status(400).send({ 122 | error: 'playerId must be numeric' 123 | }); 124 | } else if (req.query.seasonType && req.query.seasonType != 'regular' && req.query.seasonType != 'postseason' && req.query.seasonType != 'both') { 125 | res.status(400).send({ 126 | error: 'Invalid season type' 127 | }); 128 | } else { 129 | const results = await service.getPPAByPlayerGame(req.query.year, req.query.week, req.query.position, req.query.team, req.query.playerId, req.query.threshold, req.query.excludeGarbageTime, req.query.seasonType); 130 | res.send(results); 131 | } 132 | } catch (err) { 133 | Sentry.captureException(err); 134 | res.status(500).send({ 135 | error: 'Something went wrong.' 136 | }); 137 | } 138 | }; 139 | 140 | const getPPAByPlayerSeason = async (req, res) => { 141 | try { 142 | if (req.query.year && !parseInt(req.query.year)) { 143 | res.status(400).send({ 144 | error: 'year must be numeric' 145 | }); 146 | } else if (req.query.week && !parseInt(req.query.week)) { 147 | res.status(400).send({ 148 | error: 'week must be numeric' 149 | }); 150 | } else if (req.query.threshold && !parseInt(req.query.threshold)) { 151 | res.status(400).send({ 152 | error: 'threshold must by numeric' 153 | }); 154 | } else if (req.query.playerId && !parseInt(req.query.playerId)) { 155 | res.status(400).send({ 156 | error: 'playerId must be numeric' 157 | }); 158 | } else { 159 | const results = await service.getPPAByPlayerSeason(req.query.year, req.query.conference, req.query.position, req.query.team, req.query.playerId, req.query.threshold, req.query.excludeGarbageTime); 160 | res.send(results); 161 | } 162 | } catch (err) { 163 | Sentry.captureException(err); 164 | res.status(500).send({ 165 | error: 'Something went wrong.' 166 | }); 167 | } 168 | }; 169 | 170 | const getPregameWP = async (req, res) => { 171 | try { 172 | if (req.query.year && !parseInt(req.query.year)) { 173 | res.status(400).send({ 174 | error: 'year must be numeric' 175 | }); 176 | } else if (req.query.week && !parseInt(req.query.week)) { 177 | res.status(400).send({ 178 | error: 'week must be numeric' 179 | }); 180 | } else { 181 | const results = await service.getPregameWP(req.query.year, req.query.week, req.query.team, req.query.seasonType); 182 | res.send(results); 183 | } 184 | } catch (err) { 185 | Sentry.captureException(err); 186 | res.status(500).send({ 187 | error: 'Something went wrong.' 188 | }); 189 | } 190 | }; 191 | 192 | const getFGEP = async (req, res) => { 193 | try { 194 | let results = await service.getFGEP(); 195 | res.send(results); 196 | } catch (err) { 197 | Sentry.captureException(err); 198 | res.status(500).send({ 199 | error: 'Something went wrong.' 200 | }); 201 | } 202 | }; 203 | 204 | return { 205 | getPP, 206 | getPPAByTeam, 207 | getWP, 208 | getPPAByGame, 209 | getPPAByPlayerGame, 210 | getPPAByPlayerSeason, 211 | getPregameWP, 212 | getFGEP 213 | } 214 | } -------------------------------------------------------------------------------- /app/recruiting/recruiting.controller.js: -------------------------------------------------------------------------------- 1 | module.exports = (db, Sentry) => { 2 | const getPlayers = async (req, res) => { 3 | try { 4 | if (!req.query.year && !req.query.team) { 5 | res.status(400).send({ 6 | error: 'A year or team filter must be specified.' 7 | }); 8 | 9 | return; 10 | } 11 | 12 | if (req.query.year && isNaN(req.query.year)) { 13 | res.status(400).send({ 14 | error: 'Year parameter must be numeric.' 15 | }); 16 | 17 | return; 18 | } 19 | 20 | let filter = 'WHERE r.recruit_type = $1'; 21 | let params = [ 22 | req.query.classification ? req.query.classification : 'HighSchool' 23 | ]; 24 | 25 | let index = 2; 26 | 27 | if (req.query.year) { 28 | filter += ` AND r.year = $${index}`; 29 | params.push(req.query.year); 30 | index++; 31 | } 32 | 33 | if (req.query.position) { 34 | filter += ` AND LOWER(pos.position) = LOWER($${index})`; 35 | params.push(req.query.position); 36 | index++; 37 | } 38 | 39 | if (req.query.state) { 40 | filter += ` AND LOWER(h.state) = LOWER($${index})`; 41 | params.push(req.query.state); 42 | index++; 43 | } 44 | 45 | if (req.query.team) { 46 | filter += ` AND LOWER(t.school) = LOWER($${index})`; 47 | params.push(req.query.team); 48 | index++; 49 | } 50 | 51 | let recruits = await db.any(` 52 | SELECT r.id, r.recruit_type, r.year, r.ranking, r.name, rs.name AS school, pos.position, r.height, r.weight, r.stars, r.rating, t.school AS committed_to, h.city AS city, h.state AS state_province, h.country AS country, h.latitude, h.longitude, h.county_fips, a.id AS athlete_id 53 | FROM recruit AS r 54 | LEFT JOIN recruit_school AS rs ON r.recruit_school_id = rs.id 55 | LEFT JOIN recruit_position AS pos ON r.recruit_position_id = pos.id 56 | LEFT JOIN team AS t ON r.college_id = t.id 57 | LEFT JOIN hometown AS h ON r.hometown_id = h.id 58 | LEFT JOIN athlete AS a ON r.athlete_id = a.id 59 | ${filter} 60 | ORDER BY r.ranking 61 | `, params); 62 | 63 | res.send(recruits.map(r => ({ 64 | id: r.id, 65 | athleteId: r.athlete_id, 66 | recruitType: r.recruit_type, 67 | year: r.year, 68 | ranking: r.ranking, 69 | name: r.name, 70 | school: r.school, 71 | committedTo: r.committed_to, 72 | position: r.position, 73 | height: r.height, 74 | weight: r.weight, 75 | stars: r.stars, 76 | rating: r.rating, 77 | city: r.city, 78 | stateProvince: r.state_province, 79 | country: r.country, 80 | hometownInfo: { 81 | latitude: r.latitude, 82 | longitude: r.longitude, 83 | fipsCode: r.county_fips 84 | } 85 | }))); 86 | } catch (err) { 87 | Sentry.captureException(err); 88 | res.status(500).send({ 89 | error: 'Something went wrong.' 90 | }); 91 | } 92 | } 93 | 94 | const getTeams = async (req, res) => { 95 | try { 96 | let filter = ''; 97 | let params = []; 98 | let index = 1; 99 | 100 | if (req.query.year || req.query.team) { 101 | filter += 'WHERE'; 102 | if (req.query.year) 103 | if (!parseInt(req.query.year)) { 104 | res.status(400).send({ 105 | error: 'Year must be numeric' 106 | }); 107 | return; 108 | } else { 109 | filter += ` rt.year = $${index}`; 110 | params.push(req.query.year); 111 | index++; 112 | } 113 | 114 | if (req.query.team) { 115 | if (params.length) { 116 | filter += ' AND'; 117 | } 118 | filter += ` LOWER(t.school) = LOWER($${index})`; 119 | params.push(req.query.team); 120 | } 121 | } 122 | 123 | let ranks = await db.any(` 124 | SELECT rt.year, rt.rank, t.school AS team, rt.points 125 | FROM recruiting_team AS rt 126 | INNER JOIN team AS t ON rt.team_id = t.id 127 | ${filter} 128 | ORDER BY year, rank 129 | `, params); 130 | 131 | res.send(ranks); 132 | 133 | } catch (err) { 134 | Sentry.captureException(err); 135 | res.status(500).send({ 136 | error: 'Something went wrong.' 137 | }); 138 | } 139 | }; 140 | 141 | const getAggregatedPlayers = async (req, res) => { 142 | try { 143 | if (req.query.startYear && !parseInt(req.query.startYear)) { 144 | res.status(400).send({ 145 | error: 'startYear must be a nubmer' 146 | }); 147 | } else if (req.query.endYear && !parseInt(req.query.endYear)) { 148 | res.status(400).send({ 149 | error: 'endYear must be a number' 150 | }); 151 | } else { 152 | let filter = `WHERE r.recruit_type = 'HighSchool' AND r.year <= $1 AND r.year >= $2`; 153 | let params = [ 154 | req.query.endYear ? req.query.endYear : new Date().getFullYear(), 155 | req.query.startYear ? req.query.startYear : 2000 156 | ]; 157 | let index = 3; 158 | 159 | if (req.query.conference) { 160 | filter += ` AND LOWER(c.abbreviation) = LOWER($${index})`; 161 | params.push(req.query.conference); 162 | index++; 163 | } 164 | 165 | if (req.query.team) { 166 | filter += ` AND LOWER(t.school) = LOWER($${index})`; 167 | params.push(req.query.team); 168 | index++; 169 | } 170 | 171 | let results = await db.any(` 172 | SELECT t.school, p.position_group, c.name as conference, AVG(r.rating) AS avg_rating, SUM(r.rating) AS total_rating, COUNT(r.id) AS total_commits, AVG(stars) AS avg_stars 173 | FROM recruit_position AS p 174 | INNER JOIN recruit AS r ON p.id = r.recruit_position_id 175 | INNER JOIN team AS t ON r.college_id = t.id 176 | INNER JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= $1 AND (ct.end_year IS NULL OR ct.end_year >= $1) 177 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 178 | ${filter} 179 | GROUP BY t.school, p.position_group, c.name 180 | ORDER BY t.school, p.position_group 181 | `, params); 182 | 183 | let totalResults = await db.any(` 184 | SELECT t.school, 'All Positions' AS position_group, c.name as conference, AVG(r.rating) AS avg_rating, SUM(r.rating) AS total_rating, COUNT(r.id) AS total_commits, AVG(stars) AS avg_stars 185 | FROM recruit_position AS p 186 | INNER JOIN recruit AS r ON p.id = r.recruit_position_id 187 | INNER JOIN team AS t ON r.college_id = t.id 188 | INNER JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= $1 AND (ct.end_year IS NULL OR ct.end_year >= $1) 189 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 190 | ${filter} 191 | GROUP BY t.school, c.name 192 | ORDER BY t.school 193 | `, params); 194 | 195 | results = [ 196 | ...results, 197 | ...totalResults 198 | ]; 199 | 200 | res.send(results.map(r => ({ 201 | team: r.school, 202 | conference: r.conference, 203 | positionGroup: r.position_group, 204 | averageRating: parseFloat(r.avg_rating), 205 | totalRating: parseFloat(r.total_rating), 206 | commits: parseInt(r.total_commits), 207 | averageStars: parseFloat(r.avg_stars) 208 | }))); 209 | } 210 | } catch (err) { 211 | Sentry.captureException(err); 212 | res.status(500).send({ 213 | error: 'Something went wrong' 214 | }); 215 | } 216 | } 217 | 218 | return { 219 | getPlayers, 220 | getTeams, 221 | getAggregatedPlayers 222 | }; 223 | }; -------------------------------------------------------------------------------- /.github/workflows/dockerpush.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | # Publish `main` as Docker `latest` image. 6 | branches: 7 | - main 8 | 9 | # Publish `v1.2.3` tags as releases. 10 | tags: 11 | - v* 12 | 13 | pull_request: 14 | 15 | env: 16 | # TODO: Change variable to your image's name. 17 | IMAGE_NAME: cfb-api 18 | 19 | jobs: 20 | # Run tests. 21 | # See also https://docs.docker.com/docker-hub/builds/automated-testing/ 22 | # test: 23 | # runs-on: ubuntu-latest 24 | 25 | # steps: 26 | # - uses: actions/checkout@v2 27 | # - uses: actions/setup-node@v1 28 | 29 | # - name: Run tests 30 | # run: | 31 | # npm install 32 | # npm test 33 | # env: 34 | # HOST: ${{ secrets.DATABASE_HOST }} 35 | # DATABASE_USER: ${{ secrets.DATABASE_USER }} 36 | # DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} 37 | # DATABASE_PORT: ${{ secrets.DATABASE_PORT }} 38 | # DATABASE: ${{ secrets.DATABASE }} 39 | 40 | # Push image to GitHub Package Registry. 41 | # See also https://docs.docker.com/docker-hub/builds/ 42 | push: 43 | # Ensure test job passes before pushing image. 44 | # needs: test 45 | 46 | runs-on: ubuntu-latest 47 | if: github.event_name == 'push' 48 | 49 | steps: 50 | - uses: actions/checkout@v2 51 | 52 | - name: Build image 53 | run: docker build . --file Dockerfile --tag image 54 | 55 | - name: Log into registry 56 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 57 | 58 | - name: Push image 59 | run: | 60 | IMAGE_ID=docker.pkg.github.com/cfbd/cfb-api/$IMAGE_NAME 61 | 62 | # Strip git ref prefix from version 63 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 64 | 65 | # Strip "v" prefix from tag name 66 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 67 | 68 | # Use Docker `latest` tag convention 69 | [ "$VERSION" == "main" ] && VERSION=latest 70 | 71 | echo IMAGE_ID=$IMAGE_ID 72 | echo VERSION=$VERSION 73 | 74 | docker tag image $IMAGE_ID:$VERSION 75 | docker push $IMAGE_ID:$VERSION 76 | deploy: 77 | needs: push 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Deploy via SSH 81 | uses: appleboy/ssh-action@master 82 | with: 83 | host: ${{ secrets.HOST }} 84 | username: ${{ secrets.USERNAME }} 85 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 86 | key: ${{ secrets.SSH_KEY }} 87 | script: | 88 | cd /docker/cfb 89 | docker pull docker.pkg.github.com/cfbd/cfb-api/cfb-api 90 | docker-compose up -d 91 | # publish-js: 92 | # needs: deploy 93 | # runs-on: ubuntu-latest 94 | # continue-on-error: true 95 | # steps: 96 | # - name: Install dependencies 97 | # run: | 98 | # sudo apt install wget -y 99 | # sudo apt install unzip -y 100 | # sudo apt install jq -y 101 | # sudo apt install curl -y 102 | # - name: Git config 103 | # run: | 104 | # git config --global user.email "radjewwj@gmail.com" 105 | # git config --global user.name "BlueSCar" 106 | # - name: Generate library 107 | # id: lib 108 | # run: | 109 | # code=$(curl -X POST -H "content-type:application/json" -d '{"swaggerUrl": "https://api.collegefootballdata.com/api-docs.json", "options": {"projectName": "cfb.js", "moduleName": "cfb", "licenseName": "MIT", "usePromises": "true", "useES6": "false"}}' https://generator.swagger.io/api/gen/clients/javascript | jq '. | .code') 110 | # code=${code%\"} 111 | # code=${code#\"} 112 | # echo "::set-output name=code::$code" 113 | # - name: Extract and push 114 | # continue-on-error: true 115 | # run: | 116 | # echo extracting codebase... 117 | # wget https://generator.swagger.io/api/gen/download/${{ steps.lib.outputs.code }} 118 | # unzip ${{ steps.lib.outputs.code }} 119 | # echo cloning codebase... 120 | # git clone https://BlueSCar:${{ secrets.ACCESS_TOKEN }}@github.com/cfbd/cfb.js.git 121 | # sudo yes | cp -a ./javascript-client/. cfb.js 122 | # cd cfb.js 123 | # echo installing json module... 124 | # sudo npm i -g json 125 | # echo updating package.config... 126 | # json -I -f package.json -e 'this.keywords=["football","cfb","ncaaf","data","statistics"]' 127 | # json -I -f package.json -e 'this.author="BlueSCar"' 128 | # json -I -f package.json -e 'this.repository={"type": "git", "url": "https://github.com/cfbd/cfb.js.git"}' 129 | # sed -i '103,104d' README.md 130 | # sed -i '102s/.*/ApiKeyAuth.apiKey = "Bearer YOUR_API_KEY";/' README.md 131 | # sed -i '26,59d' README.md 132 | # echo "Adding changes..." 133 | # git add . 134 | # echo "Committing changes ..." 135 | # git commit -am "${{ github.event.commits[0].message }}" 136 | # echo "Pushing to remote..." 137 | # git push origin master 2>&1 | grep -v 'To https' 138 | # publish-csharp: 139 | # needs: deploy 140 | # runs-on: ubuntu-latest 141 | # continue-on-error: true 142 | # steps: 143 | # - name: Install dependencies 144 | # run: | 145 | # sudo apt install wget -y 146 | # sudo apt install unzip -y 147 | # sudo apt install jq -y 148 | # sudo apt install curl -y 149 | # - name: Git config 150 | # run: | 151 | # git config --global user.email "radjewwj@gmail.com" 152 | # git config --global user.name "BlueSCar" 153 | # - name: Generate library 154 | # id: lib 155 | # run: | 156 | # code=$(curl -X POST -H "content-type:application/json" -d '{"swaggerUrl": "https://api.collegefootballdata.com/api-docs.json", "options": { "packageName": "CFBSharp", "targetFramework": "v5.0", "returnICollection": "true", "netCoreProjectFile": "true", "validatable": "true"}}' https://generator.swagger.io/api/gen/clients/csharp | jq '. | .code') 157 | # code=${code%\"} 158 | # code=${code#\"} 159 | # echo "::set-output name=code::$code" 160 | # - name: Extract and push 161 | # run: | 162 | # echo Extracting new codebase... 163 | # wget https://generator.swagger.io/api/gen/download/${{ steps.lib.outputs.code }} 164 | # unzip ${{ steps.lib.outputs.code }} 165 | # echo cloning existing codebase.... 166 | # git clone https://BlueSCar:${{ secrets.ACCESS_TOKEN }}@github.com/cfbd/CFBSharp.git 167 | # echo copying changes.... 168 | # sudo yes | cp -a ./csharp-client/. CFBSharp 169 | # cd CFBSharp 170 | # git add . 171 | # echo Updating files 172 | # sed -i '26s/.*/Add the package using the dotnet CLI:\r\n```powershell\r\ndotnet add package CFBSharp\r\n```/' README.md 173 | # sed -i '31s/.*/Then use the relevant namespaces:/' README.md 174 | # match=$(grep -E 'API version: [0-9]+\.[0-9]+\.[0-9]+' README.md) 175 | # api_version=$(echo $match | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') 176 | # sed -i '8s/.*/- SDK version: '"$api_version"'/' README.md 177 | # sed -i '56d' README.md 178 | # sed -i '56s/.*/ Configuration.Default.ApiKeyPrefix.Add("Authorization", "Bearer");/' README.md 179 | # sed -i '17s/.*/ '"$api_version"'<\/Version>/' ./src/CFBSharp/CFBSharp.csproj 180 | # sed -i '4s/.*/ netstandard2.1<\/TargetFramework>/' ./src/CFBSharp/CFBSharp.csproj 181 | # sed -i '17a\ \ \ \ https://github.com/cfbd/CFBSharp.git<\/RepositoryUrl>' ./src/CFBSharp/CFBSharp.csproj 182 | # sed -i '18a\ \ \ \ git<\/RepositoryType>' ./src/CFBSharp/CFBSharp.csproj 183 | # sed -i '19a\ \ \ \ https://cfbd-public.s3.us-east-2.amazonaws.com/package_logo.png<\/PackageIconUrl>' ./src/CFBSharp/CFBSharp.csproj 184 | # sed -i '20a\ \ \ \ MIT<\/PackageLicenseExpression>' ./src/CFBSharp/CFBSharp.csproj 185 | # sed -i '21a\ \ \ \ CFB;NCAAF;NCAA;football<\/PackageTags>' ./src/CFBSharp/CFBSharp.csproj 186 | # echo "Committing changes ..." 187 | # git commit -am "${{ github.event.commits[0].message }}" 188 | # echo "Pushing to remote..." 189 | # git push origin master 2>&1 | grep -v 'To https' 190 | # publish-python: 191 | # needs: deploy 192 | # runs-on: ubuntu-latest 193 | # continue-on-error: true 194 | # steps: 195 | # - name: Install dependencies 196 | # run: | 197 | # sudo apt install wget -y 198 | # sudo apt install unzip -y 199 | # sudo apt install jq -y 200 | # sudo apt install curl -y 201 | # - name: Git config 202 | # run: | 203 | # git config --global user.email "radjewwj@gmail.com" 204 | # git config --global user.name "BlueSCar" 205 | # - name: Generate library 206 | # id: lib 207 | # run: | 208 | # api_version=$(curl https://api.collegefootballdata.com/api-docs.json | jq .info.version) 209 | # code=$(curl -X POST -H "content-type:application/json" -d '{"swaggerUrl": "https://api.collegefootballdata.com/api-docs.json", "options": { "packageName": "cfbd", "projectName": "cfbd", "packageUrl": "https://github.com/CFBD/cfbd-python", "packageVersion": '"${api_version}"'}}' https://generator.swagger.io/api/gen/clients/python | jq '. | .code') 210 | # code=${code%\"} 211 | # code=${code#\"} 212 | # echo "::set-output name=code::$code" 213 | # - name: Extract and push 214 | # run: | 215 | # echo Extracting new codebase... 216 | # wget https://generator.swagger.io/api/gen/download/${{ steps.lib.outputs.code }} 217 | # unzip ${{ steps.lib.outputs.code }} 218 | # echo cloning existing codebase.... 219 | # git clone https://BlueSCar:${{ secrets.ACCESS_TOKEN }}@github.com/cfbd/cfbd-python.git 220 | # echo copying changes.... 221 | # sudo yes | cp -a ./python-client/. cfbd-python 222 | # cd cfbd-python 223 | # git add . 224 | # echo Updating files 225 | # sed -i '16,17d' README.md 226 | # sed -i '55d' README.md 227 | # sed -i '55s/# //' README.md 228 | # sed -i -e 's/git+https:\/\/github.com\/\/.git/cfbd/g' README.md 229 | # echo "Committing changes ..." 230 | # git commit -am "${{ github.event.commits[0].message }}" 231 | # echo "Pushing to remote..." 232 | # git push origin master 2>&1 | grep -v 'To https' 233 | -------------------------------------------------------------------------------- /app/play/play.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getPlays = async (year, week, team, offense, defense, offenseConference, defenseConference, conference, playType, seasonType, classification) => { 3 | let filter = 'WHERE g.season = $1'; 4 | let params = [year]; 5 | 6 | let index = 2; 7 | 8 | if (week) { 9 | 10 | filter += ` AND g.week = $${index}`; 11 | params.push(week); 12 | index++; 13 | } 14 | 15 | if (team) { 16 | filter += ` AND (LOWER(offense.school) = LOWER($${index}) OR LOWER(defense.school) = LOWER($${index}))`; 17 | params.push(team); 18 | index++; 19 | } 20 | 21 | if (offense) { 22 | filter += ` AND LOWER(offense.school) = LOWER($${index})`; 23 | params.push(offense); 24 | index++; 25 | } 26 | 27 | if (defense) { 28 | filter += ` AND LOWER(defense.school) = LOWER($${index})`; 29 | params.push(defense); 30 | index++; 31 | } 32 | 33 | if (offenseConference) { 34 | filter += ` AND LOWER(oc.abbreviation) = LOWER($${index})`; 35 | params.push(offenseConference); 36 | index++; 37 | } 38 | 39 | if (defenseConference) { 40 | filter += ` AND LOWER(dc.abbreviation) = LOWER($${index})`; 41 | params.push(defenseConference); 42 | index++; 43 | } 44 | 45 | if (conference) { 46 | filter += ` AND (LOWER(oc.abbreviation) = LOWER($${index}) OR LOWER(dc.abbreviation) = LOWER($${index}))`; 47 | params.push(conference); 48 | index++; 49 | } 50 | 51 | if (playType) { 52 | filter += ` AND pt.id = $${index}`; 53 | params.push(playType); 54 | index++; 55 | } 56 | 57 | if (seasonType != 'both') { 58 | filter += ` AND g.season_type = $${index}`; 59 | params.push(seasonType || 'regular'); 60 | index++; 61 | } 62 | 63 | if (classification && ['fbs', 'fcs', 'ii', 'iii'].includes(classification.toLowerCase())) { 64 | filter += ` AND (oc.division = $${index} OR dc.division = $${index})`; 65 | params.push(classification.toLowerCase()); 66 | index++; 67 | } 68 | 69 | let plays = await db.any(` 70 | SELECT p.id, 71 | offense.school as offense, 72 | oc.name as offense_conference, 73 | defense.school as defense, 74 | dc.name as defense_conference, 75 | CASE WHEN ogt.home_away = 'home' THEN offense.school ELSE defense.school END AS home, 76 | CASE WHEN ogt.home_away = 'away' THEN offense.school ELSE defense.school END AS away, 77 | CASE WHEN ogt.home_away = 'home' THEN p.home_score ELSE p.away_score END AS offense_score, 78 | CASE WHEN dgt.home_away = 'home' THEN p.home_score ELSE p.away_score END AS defense_score, 79 | g.id as game_id, 80 | d.id as drive_id, 81 | g.id as game_id, 82 | d.drive_number, 83 | p.play_number, 84 | p.period, 85 | p.clock, 86 | CASE WHEN ogt.home_away = 'home' THEN p.home_timeouts ELSE p.away_timeouts END AS offense_timeouts, 87 | CASE WHEN dgt.home_away = 'home' THEN p.home_timeouts ELSE p.away_timeouts END AS defense_timeouts, 88 | p.yard_line, 89 | CASE WHEN ogt.home_away = 'home' THEN (100 - p.yard_line) ELSE p.yard_line END AS yards_to_goal, 90 | p.down, 91 | p.distance, 92 | p.scoring, 93 | p.yards_gained, 94 | pt.text as play_type, 95 | p.play_text, 96 | p.ppa, 97 | p.wallclock 98 | FROM game g 99 | INNER JOIN drive d ON g.id = d.game_id 100 | INNER JOIN play p ON d.id = p.drive_id 101 | INNER JOIN team offense ON p.offense_id = offense.id 102 | LEFT JOIN conference_team oct ON offense.id = oct.team_id AND oct.start_year <= g.season AND (oct.end_year >= g.season OR oct.end_year IS NULL) 103 | LEFT JOIN conference oc ON oct.conference_id = oc.id 104 | INNER JOIN team defense ON p.defense_id = defense.id 105 | LEFT JOIN conference_team dct ON defense.id = dct.team_id AND dct.start_year <= g.season AND (dct.end_year >= g.season OR dct.end_year IS NULL) 106 | LEFT JOIN conference dc ON dct.conference_id = dc.id 107 | INNER JOIN game_team ogt ON ogt.game_id = g.id AND ogt.team_id = offense.id 108 | INNER JOIN game_team dgt ON dgt.game_id = g.id AND dgt.team_id = defense.id 109 | INNER JOIN play_type pt ON p.play_type_id = pt.id 110 | ${filter} 111 | ORDER BY g.id, d.drive_number, p.play_number 112 | `, params); 113 | 114 | for (let play of plays) { 115 | if (!play.clock.minutes) { 116 | play.clock.minutes = 0; 117 | } 118 | 119 | if (!play.clock.seconds) { 120 | play.clock.seconds = 0; 121 | } 122 | } 123 | 124 | return plays; 125 | }; 126 | 127 | const getPlayTypes = async () => { 128 | const types = await db.any(` 129 | SELECT id, text, abbreviation 130 | FROM play_type 131 | ORDER BY "sequence" 132 | `); 133 | 134 | return types; 135 | }; 136 | 137 | const getPlayStatTypes = async () => { 138 | const types = await db.any(` 139 | SELECT id, name 140 | FROM play_stat_type 141 | `); 142 | 143 | return types; 144 | }; 145 | 146 | const getPlayStats = async ( 147 | year, 148 | week, 149 | team, 150 | gameId, 151 | athleteId, 152 | statTypeId, 153 | seasonType, 154 | conference 155 | ) => { 156 | let filters = []; 157 | let params = []; 158 | let index = 1; 159 | 160 | if (year) { 161 | filters.push(`g.season = $${index}`); 162 | params.push(year); 163 | index++; 164 | } 165 | 166 | if (week) { 167 | filters.push(`g.week = $${index}`); 168 | params.push(week); 169 | index++; 170 | } 171 | 172 | if (team) { 173 | filters.push(`LOWER(t.school) = LOWER($${index})`); 174 | params.push(team); 175 | index++; 176 | } 177 | 178 | if (gameId) { 179 | filters.push(`g.id = $${index}`); 180 | params.push(gameId); 181 | index++; 182 | } 183 | 184 | if (athleteId) { 185 | filters.push(`a.id = $${index}`); 186 | params.push(athleteId); 187 | index++; 188 | } 189 | 190 | if (statTypeId) { 191 | filters.push(`pst.id = $${index}`); 192 | params.push(statTypeId); 193 | index++; 194 | } 195 | 196 | if (seasonType != 'both') { 197 | filters.push(`g.season_type = $${index}`); 198 | params.push(seasonType || 'regular'); 199 | index++; 200 | } 201 | 202 | if (conference) { 203 | filters.push(`LOWER(c.abbreviation) = LOWER($${index})`); 204 | params.push(conference); 205 | index++; 206 | } 207 | 208 | let filter = `WHERE ${filters.join(' AND ')}`; 209 | 210 | const results = await db.any(` 211 | SELECT g.id as game_id, 212 | g.season, 213 | g.week, 214 | t.school AS team, 215 | c.name AS conference, 216 | t2.school AS opponent, 217 | CASE 218 | WHEN gt.home_away = 'home' THEN p.home_score 219 | ELSE p.away_score 220 | END AS team_score, 221 | CASE 222 | WHEN gt2.home_away = 'home' THEN p.home_score 223 | ELSE p.away_score 224 | END AS opponent_score, 225 | d.id AS drive_id, 226 | p.id AS play_id, 227 | p.period, 228 | p.clock, 229 | CASE 230 | WHEN (gt.home_away = 'home' AND p.offense_id = t.id) OR (gt.home_away = 'away' AND p.defense_id = t.id) THEN 100 - p.yard_line 231 | ELSE p.yard_line 232 | END AS yards_to_goal, 233 | p.down, 234 | p.distance, 235 | a.id AS athlete_id, 236 | a.name AS athlete_name, 237 | pst.name AS stat_name, 238 | ps.stat 239 | FROM team AS t 240 | INNER JOIN game_team AS gt ON t.id = gt.team_id 241 | INNER JOIN game_team AS gt2 ON gt2.game_id = gt.game_id AND gt.id <> gt2.id 242 | INNER JOIN team AS t2 ON gt2.team_id = t2.id 243 | INNER JOIN game AS g ON gt.game_id = g.id 244 | INNER JOIN drive AS d ON g.id = d.game_id 245 | INNER JOIN play AS p ON d.id = p.drive_id 246 | INNER JOIN play_stat AS ps ON p.id = ps.play_id 247 | INNER JOIN athlete AS a ON a.id = ps.athlete_id 248 | INNER JOIN athlete_team AS att ON a.id = att.athlete_id AND att.start_year <= g.season AND att.end_year >= g.season AND att.team_id = t.id 249 | INNER JOIN play_stat_type AS pst ON ps.stat_type_id = pst.id 250 | INNER JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= g.season AND (ct.end_year IS NULL OR ct.end_year >= g.season) 251 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 252 | ${filter} 253 | LIMIT 2000 254 | `, params); 255 | 256 | for (let play of results) { 257 | if (!play.clock.minutes) { 258 | play.clock.minutes = 0; 259 | } 260 | 261 | if (!play.clock.seconds) { 262 | play.clock.seconds = 0; 263 | } 264 | } 265 | 266 | return results.map(r => ({ 267 | gameId: r.game_id, 268 | season: r.season, 269 | week: r.week, 270 | team: r.team, 271 | conference: r.conference, 272 | opponent: r.opponent, 273 | teamScore: r.team_score, 274 | opponentScore: r.opponent_score, 275 | driveId: r.drive_id, 276 | playId: r.play_id, 277 | period: r.period, 278 | secondsRemaining: r.seconds_remaining, 279 | yardsToGoal: r.yards_to_goal, 280 | down: r.down, 281 | distance: r.distance, 282 | athleteId: r.athlete_id, 283 | athleteName: r.athlete_name, 284 | statType: r.stat_name, 285 | stat: r.stat 286 | })); 287 | } 288 | 289 | return { 290 | getPlays, 291 | getPlayTypes, 292 | getPlayStatTypes, 293 | getPlayStats 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /app/live/live.service.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | module.exports = async (db) => { 4 | const PLAYS_URL = process.env.PLAYS_URL; 5 | 6 | // const epaTypes = [8,12,13,14,15,16,17,18,21,29,40,41,43,52,53,56,57,59,60,61,62,65,66,67,999,78]; 7 | const epaTypes = [3,4,6,7,24,26,36,51,67,5,9,29,39,68,18,38,40,41,59,60]; 8 | const passTypes = [3,4,6,7,24,26,36,37,38,39,51,67]; 9 | const rushTypes = [5,9,29,39,68]; 10 | const unsuccessfulTypes = [20,26,34,36,37,38,39,63]; 11 | const ppas = await db.any('SELECT yard_line, down, distance, ROUND(predicted_points, 3) AS ppa FROM ppa'); 12 | 13 | const getPlaySuccess = (play) => { 14 | const typeId = parseInt(play.type.id); 15 | return !unsuccessfulTypes.includes(typeId) && (play.scoringPlay 16 | || (play.start.down == 1 && play.statYardage >= (play.start.distance / 2)) 17 | || (play.start.down == 2 && play.statYardage >= (play.start.distance * .7)) 18 | || (play.start.down > 2 && play.statYardage >= play.start.distance)); 19 | }; 20 | 21 | const getPlayType = (play) => { 22 | const typeId = parseInt(play.type.id); 23 | if (passTypes.includes(typeId)) { 24 | return 'pass'; 25 | } 26 | 27 | if (rushTypes.includes(typeId)) { 28 | return 'rush'; 29 | } 30 | 31 | return 'other'; 32 | }; 33 | 34 | const getDownType = (play) => { 35 | return ((play.start.down == 2 && play.start.distance >= 7) || (play.start.down > 2 && play.start.distance >= 5)) ? 'passing' : 'standard'; 36 | }; 37 | 38 | const getGarbageTime = (play) => { 39 | let score = Math.abs(play.homeScore - play.awayScore); 40 | 41 | if (play.scoringPlay && play.scoringType.abbreviation == 'TD') { 42 | score -= 7; 43 | } else if (play.scoringPlay && play.scoringType.abbreviation == 'FG') { 44 | score -= 3; 45 | } 46 | 47 | return (play.period == 2 && score <= 38) 48 | || (play.period == 3 && score <= 28) 49 | || (play.period == 4 && score <= 22); 50 | } 51 | 52 | const getPlays = async (id) => { 53 | const result = await axios.get(PLAYS_URL, { 54 | params: { 55 | gameId: id, 56 | xhr: 1, 57 | render: false 58 | } 59 | }); 60 | 61 | let comp = result.data.gamepackageJSON.header.competitions[0]; 62 | let teams = comp.competitors; 63 | let driveData = result.data.gamepackageJSON.drives.previous.filter(p => p.team); 64 | 65 | let drives = []; 66 | let plays = []; 67 | 68 | if (result.data.gamepackageJSON.drives.current && !driveData.find(d => d.id == result.data.gamepackageJSON.drives.current.id)) { 69 | driveData.push(result.data.gamepackageJSON.drives.current); 70 | } 71 | 72 | for (let drive of driveData) { 73 | let offense = teams.find(t => t.team.displayName == drive.team.displayName); 74 | let defense = teams.find(t => t.team.displayName != offense.displayName); 75 | 76 | let d = { 77 | id: drive.id, 78 | offenseId: offense.team.id, 79 | offense: offense.team.location, 80 | defenseId: defense.team.id, 81 | defense: defense.team.location, 82 | playCount: drive.offensivePlays, 83 | yards: drive.yards, 84 | startPeriod: drive.start.period.number, 85 | startClock: drive.start.clock ? drive.start.clock.displayValue : null, 86 | startYardsToGoal: offense.homeAway == 'home' ? 100 - drive.start.yardLine : drive.start.yardLine, 87 | endPeriod: drive.end ? drive.end.period.number : null, 88 | endClock: drive.end && drive.end.clock ? drive.end.clock.displayValue : null, 89 | endYardsToGoal: drive.end ? offense.homeAway == 'home' ? 100 - drive.end.yardLine : drive.end.yardLine : null, 90 | duration: drive.timeElapsed ? drive.timeElapsed.displayValue : null, 91 | scoringOpportunity: false, 92 | plays: [], 93 | result: drive.displayResult 94 | }; 95 | 96 | for (let play of drive.plays) { 97 | let playTeam = play.start.team.id == offense.team.id ? offense.team : defense.team; 98 | let epa = null; 99 | if (epaTypes.includes(parseInt(play.type.id))) { 100 | let startingEP = ppas.find(ppa => ppa.down == play.start.down && ppa.distance == play.start.distance && ppa.yard_line == play.start.yardsToEndzone); 101 | let endingEP = null; 102 | 103 | if (play.scoringPlay) { 104 | if (play.scoringType.abbreviation == 'TD') { 105 | endingEP = play.end.team.id == offense.id ? {ppa: 6} : {ppa: -6}; 106 | } else if (play.scoringType.abbreviation == 'FG') { 107 | endingEP = {ppa: 3}; 108 | } 109 | } else { 110 | endingEP = ppas.find(ppa => ppa.down == play.end.down && ppa.distance == play.end.distance && ppa.yard_line == play.end.yardsToEndzone); 111 | } 112 | 113 | if (startingEP && endingEP) { 114 | epa = Math.round((parseFloat(endingEP.ppa) - parseFloat(startingEP.ppa)) * 1000) / 1000; 115 | } 116 | } 117 | 118 | if (play.end.yardsToEndzone <= 40) { 119 | d.scoringOpportunity = true; 120 | } 121 | 122 | let p = { 123 | id: play.id, 124 | homeScore: play.homeScore, 125 | awayScore: play.awayScore, 126 | period: play.period.number, 127 | clock: play.clock ? play.clock.displayValue : null, 128 | wallclock: play.wallclock, 129 | teamId: playTeam.id, 130 | team: playTeam.location, 131 | down: play.start.down, 132 | distance: play.start.distance, 133 | yardsToGoal: play.start.yardsToEndzone, 134 | yardsGained: play.statYardage, 135 | playTypeId: play.type.id, 136 | playType: play.type.text, 137 | epa: epa, 138 | garbageTime: getGarbageTime(play), 139 | success: getPlaySuccess(play), 140 | rushPass: getPlayType(play), 141 | downType: getDownType(play), 142 | playText: play.text 143 | }; 144 | 145 | d.plays.push(p); 146 | plays.push(p); 147 | } 148 | 149 | let first = d.plays[0]; 150 | let last = d.plays[d.plays.length - 1]; 151 | let scoreDiff = (last.homeScore - last.awayScore) - (first.homeScore - first.awayScore); 152 | 153 | if (offense.homeAway == 'away') { 154 | scoreDiff *= -1; 155 | } 156 | 157 | d.pointsGained = scoreDiff; 158 | 159 | drives.push(d); 160 | } 161 | 162 | let teamStats = teams.map(t => { 163 | let teamDrives = drives.filter(d => d.offenseId == t.team.id); 164 | let scoringOpps = teamDrives.filter(d => d.scoringOpportunity); 165 | let teamPlays = plays.filter(p => p.epa && p.teamId == t.team.id); 166 | let rushingPlays = teamPlays.filter(p => p.rushPass == 'rush'); 167 | let passingPlays = teamPlays.filter(p => p.rushPass == 'pass'); 168 | let standardDowns = teamPlays.filter(p => p.downType == 'standard'); 169 | let passingDowns = teamPlays.filter(p => p.downType == 'passing'); 170 | let successfulPlays = teamPlays.filter(p => p.success); 171 | 172 | let lineYards = rushingPlays.map(r => { 173 | if (r.yardsGained < 0) { 174 | return -1.2 & r.yardsGained; 175 | } else if (r.yardsGained <= 4) { 176 | return r.yardsGained; 177 | } else if (r.yardsGained <= 10) { 178 | return 4 + (r.yardsGained - 4) / 2; 179 | } else { 180 | return 7; 181 | } 182 | }).reduce((p,v) => p + v, 0); 183 | 184 | let secondLevelYards = rushingPlays.map(r => { 185 | if (r.yardsGained <= 5) { 186 | return 0; 187 | } else if (r.yardsGained < 10) { 188 | return r.yardsGained - 5; 189 | } else { 190 | return 5; 191 | } 192 | }).reduce((p,v) => p + v, 0); 193 | 194 | let openFieldYards = rushingPlays.map(r => { 195 | if (r.yardsGained <= 10) { 196 | return 0; 197 | } else { 198 | return r.yardsGained - 10; 199 | } 200 | }).reduce((p,v) => p + v, 0); 201 | 202 | return { 203 | teamId: t.team.id, 204 | team: t.team.location, 205 | homeAway: t.homeAway, 206 | lineScores: t.lineScores, 207 | points: t.score, 208 | drives: teamDrives.length, 209 | scoringOpportunities: scoringOpps.length, 210 | pointsPerOpportunity: scoringOpps.length ? Math.round((scoringOpps.map(o => o.pointsGained).reduce((p,v) => p + v, 0) / scoringOpps.length) * 10) / 10 : 0, 211 | plays: teamPlays.length, 212 | lineYards, 213 | lineYardsPerRush: rushingPlays.length > 0 ? Math.round(lineYards * 10 / rushingPlays.length) / 10 : 0, 214 | secondLevelYards, 215 | secondLevelYardsPerRush: rushingPlays.length > 0 ? Math.round(secondLevelYards * 10 / rushingPlays.length) / 10 : 0, 216 | openFieldYards, 217 | openFieldYardsPerRush: rushingPlays.length > 0 ? Math.round(openFieldYards * 10 / rushingPlays.length) / 10 : 0, 218 | epaPerPlay: teamPlays.length ? Math.round((teamPlays.map(t => t.epa).reduce((p,v) => p + v, 0) / teamPlays.length) * 1000) / 1000 : 0, 219 | totalEpa: Math.round((teamPlays.map(t => t.epa).reduce((p,v) => p + v, 0)) * 10) / 10, 220 | passingEpa: Math.round((passingPlays.map(t => t.epa).reduce((p,v) => p + v, 0)) * 10) / 10, 221 | epaPerPass: passingPlays.length ? Math.round((passingPlays.map(t => t.epa).reduce((p,v) => p + v, 0) / passingPlays.length) * 1000) / 1000 : 0, 222 | rushingEpa: Math.round((rushingPlays.map(t => t.epa).reduce((p,v) => p + v, 0)) * 10) / 10, 223 | epaPerRush: rushingPlays.length ? Math.round((rushingPlays.map(t => t.epa).reduce((p,v) => p + v, 0) / rushingPlays.length) * 1000) / 1000 : 0, 224 | successRate: teamPlays.length ? Math.round((teamPlays.map(t => t.success ? 1 : 0).reduce((p,v) => p + v, 0) / teamPlays.length) * 1000) / 1000 : 0, 225 | standardDownSuccessRate: standardDowns.length ? Math.round((standardDowns.map(t => t.success ? 1 : 0).reduce((p,v) => p + v, 0) / standardDowns.length) * 1000) / 1000 : 0, 226 | passingDownSuccessRate: passingDowns.length ? Math.round((passingDowns.map(t => t.success ? 1 : 0).reduce((p,v) => p + v, 0) / passingDowns.length) * 1000) / 1000 : 0, 227 | explosiveness: successfulPlays.length ? Math.round((successfulPlays.map(t => t.epa).reduce((p,v) => p + v, 0) / successfulPlays.length) * 1000) / 1000 : 0 228 | } 229 | }); 230 | 231 | let currentDrive = result.data.gamepackageJSON.drives.current; 232 | let currentPlay = currentDrive && currentDrive.plays && currentDrive.plays.length ? currentDrive.plays[currentDrive.plays.length - 1] : null; 233 | 234 | return { 235 | id: result.data.gameId, 236 | status: comp.status.type.description, 237 | period: comp.status.period, 238 | clock: comp.status.displayClock, 239 | possession: currentDrive ? teams.find(t => t.team.displayName == currentDrive.team.displayName).team.location : null, 240 | down: currentPlay && currentPlay.end ? currentPlay.end.down : null, 241 | distance: currentPlay && currentPlay.end ? currentPlay.end.distance : null, 242 | yardsToGoal: currentPlay && currentPlay.end ? currentPlay.end.yardsToEndzone : null, 243 | teams: teamStats, 244 | drives: drives 245 | }; 246 | }; 247 | 248 | 249 | return { 250 | getPlays 251 | } 252 | }; 253 | -------------------------------------------------------------------------------- /app/team/team.controller.js: -------------------------------------------------------------------------------- 1 | module.exports = (db, Sentry) => { 2 | return { 3 | getTeams: async (req, res) => { 4 | try { 5 | let filter = req.query.conference ? 'WHERE LOWER(c.abbreviation) = LOWER($1)' : ''; 6 | let params = [req.query.conference]; 7 | 8 | let teams = await db.any(` 9 | SELECT t.id, t.school, t.mascot, t.abbreviation, t.alt_name as alt_name1, t.abbreviation as alt_name2, t.nickname as alt_name3, t.twitter, c.division AS classification, c.name as conference, ct.division as division, ('#' || t.color) as color, ('#' || t.alt_color) as alt_color, t.images as logos, v.id AS venue_id, v.name AS venue_name, v.capacity, v.grass, v.city, v.state, v.zip, v.country_code, v.location, v.elevation, v.year_constructed, v.dome, v.timezone 10 | FROM team t 11 | LEFT JOIN venue AS v ON t.venue_id = v.id 12 | LEFT JOIN conference_team ct ON t.id = ct.team_id AND ct.end_year IS NULL 13 | LEFT JOIN conference c ON c.id = ct.conference_id 14 | ${filter} 15 | ORDER BY t.active DESC, t.school 16 | `, params); 17 | 18 | res.send(teams.map(t => ({ 19 | id: t.id, 20 | school: t.school, 21 | mascot: t.mascot, 22 | abbreviation: t.abbreviation, 23 | alt_name1: t.alt_name1, 24 | alt_name2: t.alt_name2, 25 | alt_name3: t.alt_name3, 26 | level: t.level, 27 | conference: t.conference, 28 | classification: t.classification, 29 | color: t.color, 30 | alt_color: t.alt_color, 31 | logos: t.logos, 32 | twitter: t.twitter, 33 | location: { 34 | venue_id: t.venue_id, 35 | name: t.venue_name, 36 | city: t.city, 37 | state: t.state, 38 | zip: t.zip, 39 | country_code: t.country_code, 40 | timezone: t.timezone, 41 | latitude: t.location ? t.location.x : null, 42 | longitude: t.location ? t.location.y : null, 43 | elevation: t.elevation, 44 | capacity: t.capacity, 45 | year_constructed: t.year_constructed, 46 | grass: t.grass, 47 | dome: t.dome 48 | } 49 | }))); 50 | } catch (err) { 51 | Sentry.captureException(err); 52 | res.status(500).send({ 53 | error: 'Something went wrong.' 54 | }); 55 | } 56 | }, 57 | getFBSTeams: async (req, res) => { 58 | try { 59 | if (req.query.year && !parseInt(req.query.year)) { 60 | res.status(400).send('Year must be numeric'); 61 | return; 62 | } 63 | 64 | let filter = req.query.year ? 'WHERE ct.start_year <= $1 AND (ct.end_year >= $1 OR ct.end_year IS NULL)' : 'WHERE ct.end_year IS NULL'; 65 | let params = req.query.year ? [req.query.year] : []; 66 | 67 | let teams = await db.any(` 68 | SELECT t.id, t.school, t.mascot, t.abbreviation, t.alt_name as alt_name1, t.abbreviation as alt_name2, t.nickname as alt_name3, t.twitter, c.name as conference, ct.division as division, ('#' || t.color) as color, ('#' || t.alt_color) as alt_color, t.images as logos, v.id AS venue_id, v.name AS venue_name, v.capacity, v.grass, v.city, v.state, v.zip, v.country_code, v.location, v.elevation, v.year_constructed, v.dome, v.timezone 69 | FROM team t 70 | INNER JOIN conference_team ct ON t.id = ct.team_id 71 | INNER JOIN conference c ON c.id = ct.conference_id AND c.division = 'fbs' 72 | LEFT JOIN venue AS v ON t.venue_id = v.id 73 | ${filter} 74 | ORDER BY t.active DESC, t.school 75 | `, params); 76 | 77 | res.send(teams.map(t => ({ 78 | id: t.id, 79 | school: t.school, 80 | mascot: t.mascot, 81 | abbreviation: t.abbreviation, 82 | alt_name1: t.alt_name1, 83 | alt_name2: t.alt_name2, 84 | alt_name3: t.alt_name3, 85 | conference: t.conference, 86 | division: t.division, 87 | color: t.color, 88 | alt_color: t.alt_color, 89 | logos: t.logos, 90 | twitter: t.twitter, 91 | location: { 92 | venue_id: t.venue_id, 93 | name: t.venue_name, 94 | city: t.city, 95 | state: t.state, 96 | zip: t.zip, 97 | country_code: t.country_code, 98 | timezone: t.timezone, 99 | latitude: t.location ? t.location.x : null, 100 | longitude: t.location ? t.location.y : null, 101 | elevation: t.elevation, 102 | capacity: t.capacity, 103 | year_constructed: t.year_constructed, 104 | grass: t.grass, 105 | dome: t.dome 106 | } 107 | }))); 108 | } catch (err) { 109 | Sentry.captureException(err); 110 | res.status(500).send({ 111 | error: 'Something went wrong.' 112 | }); 113 | } 114 | }, 115 | getRoster: async (req, res) => { 116 | try { 117 | let filters = []; 118 | let params = []; 119 | let index = 1; 120 | 121 | if (req.query.team) { 122 | filters.push(`LOWER(t.school) = LOWER($${index})`) 123 | params.push(req.query.team); 124 | index++; 125 | } 126 | 127 | if (req.query.year) { 128 | if (!parseInt(req.query.year)) { 129 | res.status(400).send({ 130 | error: 'year must be numeric.' 131 | }); 132 | return; 133 | } 134 | } 135 | 136 | let year = req.query.year || 2022; 137 | filters.push(`att.start_year <= $${index} AND att.end_year >= $${index}`); 138 | params.push(year); 139 | 140 | let filter = `WHERE a.id > 0 AND ${filters.join(' AND ')}`; 141 | 142 | let roster = await db.any(` 143 | SELECT a.id, a.first_name, a.last_name, t.school AS team, a.weight, a.height, a.jersey, a.year, p.abbreviation as position, h.city as home_city, h.state as home_state, h.country as home_country, h.latitude as home_latitude, h.longitude as home_longitude, h.county_fips as home_county_fips, array_agg(DISTINCT r.id) AS recruit_ids 144 | FROM team t 145 | INNER JOIN athlete_team AS att ON t.id = att.team_id 146 | INNER JOIN athlete a ON att.athlete_id = a.id 147 | LEFT JOIN hometown h ON a.hometown_id = h.id 148 | LEFT JOIN position p ON a.position_id = p.id 149 | LEFT JOIN recruit AS r ON r.athlete_id = a.id 150 | ${filter} 151 | GROUP BY a.id, a.first_name, a.last_name, t.school, a.weight, a.height, a.jersey, a.year, p.abbreviation, h.city, h.state, h.country, h.latitude, h.longitude, h.county_fips 152 | `, params); 153 | 154 | for (let r of roster) { 155 | if (r.recruit_ids && r.recruit_ids.length) { 156 | r.recruit_ids = r.recruit_ids.filter(r => r).map(r => parseInt(r)); 157 | } 158 | } 159 | 160 | res.send(roster); 161 | 162 | } catch (err) { 163 | Sentry.captureException(err); 164 | res.status(400).send({ 165 | error: 'Something went wrong.' 166 | }); 167 | } 168 | }, 169 | getConferences: async (req, res) => { 170 | try { 171 | let conferences = await db.any(` 172 | SELECT id, name, short_name, abbreviation, division as classification 173 | FROM conference 174 | ORDER BY id 175 | `); 176 | 177 | res.send(conferences); 178 | } catch (err) { 179 | Sentry.captureException(err); 180 | res.status(400).send({ 181 | error: 'Something went wrong.' 182 | }); 183 | } 184 | }, 185 | getTeamTalent: async (req, res) => { 186 | try { 187 | if (req.query.year && isNaN(req.query.year)) { 188 | res.status(400).send({ 189 | error: 'Week parameter must be numeric' 190 | }); 191 | 192 | return; 193 | } 194 | 195 | let filter = req.query.year ? 'WHERE tt.year = $1' : ''; 196 | let params = req.query.year ? [req.query.year] : []; 197 | 198 | let talent = await db.any(` 199 | SELECT tt.year, t.school, tt.talent 200 | FROM team_talent tt 201 | INNER JOIN team t ON tt.team_id = t.id 202 | ${filter} 203 | ORDER BY tt.year DESC, tt.talent DESC 204 | `, params); 205 | 206 | res.send(talent); 207 | } catch (err) { 208 | Sentry.captureException(err); 209 | res.status(400).send({ 210 | error: 'Something went wrong.' 211 | }); 212 | } 213 | }, 214 | getMatchup: async (req, res) => { 215 | try { 216 | if (!req.query.team1 || !req.query.team2) { 217 | res.status(400).send({ 218 | error: 'Two teams must be specified.' 219 | }); 220 | return; 221 | } 222 | 223 | let filter = `WHERE g.start_date < now() AND ((LOWER(home_team.school) = LOWER($1) AND LOWER(away_team.school) = LOWER($2)) OR (LOWER(away_team.school) = LOWER($1) AND LOWER(home_team.school) = LOWER($2)))`; 224 | let params = [req.query.team1, req.query.team2]; 225 | 226 | let index = 3; 227 | 228 | if (req.query.minYear) { 229 | filter += ` AND g.season >= $${index}`; 230 | params.push(req.query.minYear); 231 | index++; 232 | } 233 | 234 | if (req.query.maxYear) { 235 | filter += ` AND g.season <= $${index}`; 236 | params.push(req.query.maxYear); 237 | index++; 238 | } 239 | 240 | let results = await db.any(` 241 | SELECT g.season, g.week, g.season_type, g.start_date, g.neutral_site, v.name as venue, home_team.school as home, home.points as home_points, away_team.school as away, away.points as away_points 242 | FROM game g 243 | INNER JOIN game_team home ON g.id = home.game_id AND home.home_away = 'home' 244 | INNER JOIN team home_team ON home.team_id = home_team.id 245 | INNER JOIN game_team away ON g.id = away.game_id AND away.home_away = 'away' 246 | INNER JOIN team away_team ON away.team_id = away_team.id 247 | LEFT JOIN venue v ON g.venue_id = v.id 248 | ${filter} 249 | ORDER BY g.season 250 | `, params); 251 | 252 | let games = results.map(r => { 253 | let homePoints = r.home_points * 1.0; 254 | let awayPoints = r.away_points * 1.0; 255 | 256 | return { 257 | season: r.season, 258 | week: r.week, 259 | seasonType: r.season_type, 260 | date: r.start_date, 261 | neutralSite: r.neutral_site, 262 | venue: r.venue, 263 | homeTeam: r.home, 264 | homeScore: homePoints, 265 | awayTeam: r.away, 266 | awayScore: awayPoints, 267 | winner: homePoints == awayPoints ? null : homePoints > awayPoints ? r.home : r.away 268 | } 269 | }); 270 | 271 | let teams = Array.from(new Set([...games.map(g => g.homeTeam), ...games.map(g => g.awayTeam)])); 272 | let team1 = teams.find(t => t.toLowerCase() == req.query.team1.toLowerCase()); 273 | let team2 = teams.find(t => t.toLowerCase() == req.query.team2.toLowerCase()); 274 | 275 | let data = { 276 | team1, 277 | team2, 278 | startYear: req.query.minYear, 279 | endYear: req.query.maxYear, 280 | team1Wins: games.filter(g => g.winner == team1).length, 281 | team2Wins: games.filter(g => g.winner == team2).length, 282 | ties: games.filter(g => !g.winner).length, 283 | games: games 284 | }; 285 | 286 | res.send(data); 287 | } catch (err) { 288 | Sentry.captureException(err); 289 | res.status(400).send({ 290 | error: 'Something went wrong.' 291 | }); 292 | } 293 | } 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /app/ratings/ratings.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getSP = async (year, team) => { 3 | let filter = 'WHERE'; 4 | let index = 1; 5 | let params = []; 6 | if (year) { 7 | filter += ` r.year = $${index}`; 8 | params.push(year); 9 | index++; 10 | } 11 | 12 | if (team) { 13 | filter += `${params.length ? ' AND' : ''} LOWER(t.school) = LOWER($${index})`; 14 | params.push(team); 15 | } 16 | 17 | const ratings = await db.any(` 18 | SELECT t.school, c.name AS conference, RANK() OVER(ORDER BY r.rating DESC) AS overall_rank, RANK() OVER(ORDER BY r.o_rating DESC) AS offense_rank, RANK() OVER(ORDER BY r.d_rating) AS defense_rank, r.* 19 | FROM ratings AS r 20 | INNER JOIN team AS t ON r.team_id = t.id 21 | INNER JOIN conference_team AS ct ON ct.team_id = t.id AND ct.end_year IS NULL 22 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 23 | ${filter} 24 | ORDER BY r.year, r.rating DESC 25 | `, params); 26 | 27 | const averages = await db.any(` 28 | SELECT year, 29 | AVG(rating) AS rating, 30 | AVG(o_rating) AS o_rating, 31 | AVG(d_rating) AS d_rating, 32 | AVG(st_rating) AS st_rating, 33 | AVG(sos) AS sos, 34 | AVG(second_order_wins) AS second_order_wins, 35 | AVG(o_success) AS o_success, 36 | AVG(o_explosiveness) AS o_explosiveness, 37 | AVG(o_rushing) AS o_rushing, 38 | AVG(o_passing) AS o_passing, 39 | AVG(o_standard_downs) AS o_standard_downs, 40 | AVG(o_passing_downs) AS o_passing_downs, 41 | AVG(o_run_rate) AS o_run_rate, 42 | AVG(o_pace) AS o_pace, 43 | AVG(d_success) AS d_success, 44 | AVG(d_explosiveness) AS d_explosiveness, 45 | AVG(d_rushing) AS d_rushing, 46 | AVG(d_passing) AS d_passing, 47 | AVG(d_standard_downs) AS d_standard_downs, 48 | AVG(d_passing_downs) AS d_passing_downs, 49 | AVG(d_havoc) AS d_havoc, 50 | AVG(d_front_seven_havoc) AS d_front_seven_havoc, 51 | AVG(d_db_havoc) AS d_db_havoc 52 | FROM ratings 53 | ${year ? 'WHERE year = $1' : ''} 54 | GROUP BY year 55 | ORDER BY year 56 | `, year ? [year] : []); 57 | 58 | ratings.push(...averages.map(a => ({ 59 | school: 'nationalAverages', 60 | ...a 61 | }))); 62 | 63 | return ratings.map(r => ({ 64 | year: r.year, 65 | team: r.school, 66 | conference: r.conference, 67 | rating: parseFloat(r.rating), 68 | ranking: parseInt(r.overall_rank), 69 | secondOrderWins: parseFloat(r.second_order_wins), 70 | sos: parseFloat(r.sos), 71 | offense: { 72 | ranking: parseInt(r.offense_rank), 73 | rating: parseFloat(r.o_rating), 74 | success: parseFloat(r.o_success), 75 | explosiveness: parseFloat(r.o_explosiveness), 76 | rushing: parseFloat(r.o_rushing), 77 | passing: parseFloat(r.o_passing), 78 | standardDowns: parseFloat(r.o_standard_downs), 79 | passingDowns: parseFloat(r.o_passing_downs), 80 | runRate: parseFloat(r.o_run_rate), 81 | pace: parseFloat(r.o_pace) 82 | }, 83 | defense: { 84 | ranking: parseInt(r.defense_rank), 85 | rating: parseFloat(r.d_rating), 86 | success: parseFloat(r.d_success), 87 | explosiveness: parseFloat(r.d_explosiveness), 88 | rushing: parseFloat(r.d_rushing), 89 | passing: parseFloat(r.d_passing), 90 | standardDowns: parseFloat(r.d_standard_downs), 91 | passingDowns: parseFloat(r.d_passing_downs), 92 | havoc: { 93 | total: parseFloat(r.d_havoc), 94 | frontSeven: parseFloat(r.d_front_seven_havoc), 95 | db: parseFloat(r.d_db_havoc) 96 | } 97 | }, 98 | specialTeams: { 99 | rating: r.st_rating ? parseFloat(r.st_rating) : null 100 | } 101 | })); 102 | }; 103 | 104 | const getConferenceSP = async (year, conference) => { 105 | let filter = ''; 106 | let index = 1; 107 | let params = []; 108 | if (year) { 109 | filter += `WHERE r.year = $${index}`; 110 | params.push(year); 111 | index++; 112 | } 113 | 114 | if (conference) { 115 | if (!year) { 116 | filter += 'WHERE'; 117 | } 118 | filter += `${params.length ? ' AND' : ''} LOWER(c.abbreviation) = LOWER($${index})`; 119 | params.push(conference); 120 | } 121 | 122 | const ratings = await db.any(` 123 | SELECT year, 124 | c.name AS conference, 125 | AVG(r.rating) AS rating, 126 | AVG(r.o_rating) AS o_rating, 127 | AVG(r.d_rating) AS d_rating, 128 | AVG(r.st_rating) AS st_rating, 129 | AVG(r.sos) AS sos, 130 | AVG(r.second_order_wins) AS second_order_wins, 131 | AVG(r.o_success) AS o_success, 132 | AVG(r.o_explosiveness) AS o_explosiveness, 133 | AVG(r.o_rushing) AS o_rushing, 134 | AVG(r.o_passing) AS o_passing, 135 | AVG(r.o_standard_downs) AS o_standard_downs, 136 | AVG(r.o_passing_downs) AS o_passing_downs, 137 | AVG(r.o_run_rate) AS o_run_rate, 138 | AVG(r.o_pace) AS o_pace, 139 | AVG(r.d_success) AS d_success, 140 | AVG(r.d_explosiveness) AS d_explosiveness, 141 | AVG(r.d_rushing) AS d_rushing, 142 | AVG(r.d_passing) AS d_passing, 143 | AVG(r.d_standard_downs) AS d_standard_downs, 144 | AVG(r.d_passing_downs) AS d_passing_downs, 145 | AVG(r.d_havoc) AS d_havoc, 146 | AVG(r.d_front_seven_havoc) AS d_front_seven_havoc, 147 | AVG(r.d_db_havoc) AS d_db_havoc 148 | FROM ratings AS r 149 | INNER JOIN conference_team AS ct ON ct.team_id = r.team_id AND ct.start_year <= r.year AND (ct.end_year >= r.year OR ct.end_year IS NULL) 150 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 151 | ${filter} 152 | GROUP BY year, c.name 153 | ORDER BY c.name, year 154 | `, params); 155 | 156 | return ratings.map(r => ({ 157 | year: r.year, 158 | conference: r.conference, 159 | rating: parseFloat(r.rating), 160 | secondOrderWins: parseFloat(r.second_order_wins), 161 | sos: parseFloat(r.sos), 162 | offense: { 163 | rating: parseFloat(r.o_rating), 164 | success: parseFloat(r.o_success), 165 | explosiveness: parseFloat(r.o_explosiveness), 166 | rushing: parseFloat(r.o_rushing), 167 | passing: parseFloat(r.o_passing), 168 | standardDowns: parseFloat(r.o_standard_downs), 169 | passingDowns: parseFloat(r.o_passing_downs), 170 | runRate: parseFloat(r.o_run_rate), 171 | pace: parseFloat(r.o_pace) 172 | }, 173 | defense: { 174 | rating: parseFloat(r.d_rating), 175 | success: parseFloat(r.d_success), 176 | explosiveness: parseFloat(r.d_explosiveness), 177 | rushing: parseFloat(r.d_rushing), 178 | passing: parseFloat(r.d_passing), 179 | standardDowns: parseFloat(r.d_standard_downs), 180 | passingDowns: parseFloat(r.d_passing_downs), 181 | havoc: { 182 | total: parseFloat(r.d_havoc), 183 | frontSeven: parseFloat(r.d_front_seven_havoc), 184 | db: parseFloat(r.d_db_havoc) 185 | } 186 | }, 187 | specialTeams: { 188 | rating: r.st_rating ? parseFloat(r.st_rating) : null 189 | } 190 | })); 191 | } 192 | 193 | const getSRS = async (year, team, conference) => { 194 | let filters = []; 195 | let params = []; 196 | let index = 1; 197 | 198 | if (year) { 199 | filters.push(`s.year = $${index}`); 200 | params.push(year); 201 | index++; 202 | } 203 | 204 | if (team) { 205 | filters.push(`LOWER(t.school) = LOWER($${index})`); 206 | params.push(team); 207 | index++ 208 | } 209 | 210 | if (conference) { 211 | filters.push(`LOWER(c.abbreviation) = LOWER($${index})`); 212 | params.push(conference); 213 | index++; 214 | } 215 | 216 | filter = 'WHERE ' + filters.join(' AND '); 217 | 218 | const results = await db.any(` 219 | SELECT s.year, t.school AS team, c.name AS conference, ct.division, s.rating, RANK() OVER(ORDER BY s.rating DESC) AS ranking 220 | FROM srs AS s 221 | INNER JOIN team AS t ON s.team_id = t.id 222 | LEFT JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= s.year AND (ct.end_year >= s.year OR ct.end_year IS NULL) 223 | LEFT JOIN conference AS c ON ct.conference_id = c.id 224 | ${filter} 225 | `, params); 226 | 227 | return results; 228 | }; 229 | 230 | const getElo = async (year, week, seasonType, team, conference) => { 231 | let filter = ''; 232 | let filters = []; 233 | let params = []; 234 | let index = 1; 235 | 236 | if (year) { 237 | filters.push(`g.season = $${index}`); 238 | params.push(year); 239 | index++; 240 | } 241 | 242 | if (week) { 243 | filters.push(`g.week <= $${index}`); 244 | params.push(week); 245 | index++; 246 | } 247 | 248 | if ((seasonType && seasonType === 'regular') || week) { 249 | filters.push(`g.season_type = $${index}`); 250 | params.push('regular'); 251 | index++; 252 | } 253 | 254 | if (team) { 255 | filters.push(`LOWER(t.school) = LOWER($${index})`); 256 | params.push(team); 257 | index++ 258 | } 259 | 260 | if (conference) { 261 | filters.push(`LOWER(c.abbreviation) = LOWER($${index})`); 262 | params.push(conference); 263 | index++; 264 | } 265 | 266 | if (params.length) { 267 | filter = 'AND ' + filters.join(' AND '); 268 | } 269 | 270 | let results = await db.any(` 271 | WITH elos AS ( 272 | SELECT ROW_NUMBER() OVER(PARTITION BY g.season, t.school ORDER BY g.start_date DESC) AS rownum, g.season, t.school AS team, c.name AS conference, gt.end_elo AS elo 273 | FROM game AS g 274 | INNER JOIN game_team AS gt ON g.id = gt.game_id 275 | INNER JOIN team AS t ON gt.team_id = t.id 276 | INNER JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= g.season AND (ct.end_year IS NULL OR ct.end_year >= g.season) 277 | INNER JOIN conference AS c ON ct.conference_id = c.id AND c.division = 'fbs' 278 | WHERE gt.end_elo IS NOT NULL AND g.status = 'completed' ${filter} 279 | ) 280 | SELECT season AS year, team, conference, elo 281 | FROM elos 282 | WHERE rownum = 1 283 | `, params); 284 | 285 | return results; 286 | }; 287 | 288 | const getFpi = async (year, team, conference) => { 289 | let filter = ''; 290 | let filters = []; 291 | let params = []; 292 | let index = 1; 293 | 294 | if (year) { 295 | filters.push(`fpi.year = $${index}`); 296 | params.push(year); 297 | index++; 298 | } 299 | 300 | if (team) { 301 | filters.push(`LOWER(t.school) = LOWER($${index})`); 302 | params.push(team); 303 | index++ 304 | } 305 | 306 | if (conference) { 307 | filters.push(`LOWER(c.abbreviation) = LOWER($${index})`); 308 | params.push(conference); 309 | index++; 310 | } 311 | 312 | if (params.length) { 313 | filter = 'WHERE ' + filters.join(' AND '); 314 | } 315 | 316 | let results = await db.any(` 317 | SELECT t.school, 318 | c.name AS conference, 319 | fpi.* 320 | FROM fpi 321 | INNER JOIN team AS t ON fpi.team_id = t.id 322 | LEFT JOIN conference_team AS ct ON t.id = ct.team_id AND ct.start_year <= fpi.year AND (ct.end_year >= fpi.year OR ct.end_year IS NULL) 323 | LEFT JOIN conference AS c ON ct.conference_id = c.id 324 | ${filter} 325 | `, params); 326 | 327 | return results.map(r => ({ 328 | year: parseInt(r.year), 329 | team: r.school, 330 | conference: r.conference, 331 | fpi: parseFloat(r.fpi), 332 | resumeRanks: { 333 | strengthOfRecord: parseInt(r.strength_of_record_rank), 334 | fpi: parseInt(r.fpi_resume_rank), 335 | averageWinProbability: parseInt(r.avg_win_prob_rank), 336 | strengthOfSchedule: parseInt(r.sos_rank), 337 | remainingStrengthOfSchedule: r.remaining_sos_rank ? parseInt(r.remaining_sos_rank) : null, 338 | gameControl: parseInt(r.game_control_rank) 339 | }, 340 | efficiencies: { 341 | overall: parseFloat(r.overall_efficiency), 342 | offense: parseFloat(r.offensive_efficiency), 343 | defense: parseFloat(r.defensive_efficiency), 344 | specialTeams: parseFloat(r.special_teams_efficiency) 345 | } 346 | })); 347 | } 348 | 349 | return { 350 | getSP, 351 | getConferenceSP, 352 | getSRS, 353 | getElo, 354 | getFpi 355 | }; 356 | }; 357 | -------------------------------------------------------------------------------- /app/game/game.service.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | const getDrives = async (year, seasonType, week, team, offense, defense, offenseConference, defenseConference, conference, classification) => { 3 | let filter = 'WHERE g.season = $1'; 4 | let params = [year]; 5 | 6 | let index = 2; 7 | 8 | if (seasonType != 'both') { 9 | filter += ` AND g.season_type = $${index}`; 10 | params.push(seasonType || 'regular'); 11 | index++; 12 | } 13 | 14 | if (week) { 15 | filter += ` AND g.week = $${index}`; 16 | params.push(week); 17 | index++; 18 | } 19 | 20 | if (team) { 21 | filter += ` AND (LOWER(offense.school) = LOWER($${index}) OR LOWER(defense.school) = LOWER($${index}))`; 22 | params.push(team); 23 | index++; 24 | } 25 | 26 | if (offense) { 27 | filter += ` AND LOWER(offense.school) = LOWER($${index})`; 28 | params.push(offense); 29 | index++; 30 | } 31 | 32 | if (defense) { 33 | filter += ` AND LOWER(defense.school) = LOWER($${index})`; 34 | params.push(defense); 35 | index++; 36 | } 37 | 38 | if (offenseConference) { 39 | filter += ` AND LOWER(oc.abbreviation) = LOWER($${index})`; 40 | params.push(offenseConference); 41 | index++; 42 | } 43 | 44 | if (defenseConference) { 45 | filter += ` AND LOWER(dc.abbreviation) = LOWER($${index})`; 46 | params.push(defenseConference); 47 | index++; 48 | } 49 | 50 | if (conference) { 51 | filter += ` AND (LOWER(oc.abbreviation) = LOWER($${index}) OR LOWER(dc.abbreviation) = LOWER($${index}))`; 52 | params.push(conference); 53 | index++; 54 | } 55 | 56 | if (classification && ['fbs', 'fcs', 'ii', 'iii'].includes(classification.toLowerCase())) { 57 | filter += ` AND (oc.division = $${index} OR dc.division = $${index})`; 58 | params.push(classification.toLowerCase()); 59 | index++; 60 | } 61 | 62 | let drives = await db.any(` 63 | WITH drives AS ( 64 | SELECT offense.school as offense, 65 | oc.name as offense_conference, 66 | defense.school as defense, 67 | dc.name as defense_conference, 68 | g.id as game_id, 69 | d.id, 70 | d.drive_number, 71 | d.scoring, 72 | d.start_period, 73 | d.start_yardline, 74 | CASE WHEN offense.id = hgt.team_id THEN (100 - d.start_yardline) ELSE d.start_yardline END AS start_yards_to_goal, 75 | d.start_time, 76 | d.end_period, 77 | d.end_yardline, 78 | CASE WHEN offense.id = hgt.team_id THEN (100 - d.end_yardline) ELSE d.end_yardline END AS end_yards_to_goal, 79 | d.end_time, 80 | d.elapsed, 81 | d.plays, 82 | d.yards, 83 | dr.name as drive_result, 84 | CASE WHEN offense.id = hgt.team_id THEN true ELSE false END AS is_home_offense 85 | FROM game g 86 | INNER JOIN game_team AS hgt ON g.id = hgt.game_id AND hgt.home_away = 'home' 87 | INNER JOIN drive d ON g.id = d.game_id 88 | INNER JOIN team offense ON d.offense_id = offense.id 89 | LEFT JOIN conference_team oct ON offense.id = oct.team_id AND oct.start_year <= g.season AND (oct.end_year >= g.season OR oct.end_year IS NULL) 90 | LEFT JOIN conference oc ON oct.conference_id = oc.id 91 | INNER JOIN team defense ON d.defense_id = defense.id 92 | LEFT JOIN conference_team dct ON defense.id = dct.team_id AND dct.start_year <= g.season AND (dct.end_year >= g.season OR dct.end_year IS NULL) 93 | LEFT JOIN conference dc ON dct.conference_id = dc.id 94 | INNER JOIN drive_result dr ON d.result_id = dr.id 95 | ${filter} 96 | ORDER BY g.id, d.drive_number 97 | ), points AS ( 98 | SELECT d.id, MIN(p.home_score) AS starting_home_score, MIN(p.away_score) AS starting_away_score, MAX(p.home_score) AS ending_home_score, MAX(p.away_score) AS ending_away_score 99 | FROM drives AS d 100 | INNER JOIN play AS p ON d.id = p.drive_id 101 | GROUP BY d.id 102 | ) 103 | SELECT d.*, 104 | CASE WHEN d.is_home_offense THEN p.starting_home_score ELSE p.starting_away_score END AS start_offense_score, 105 | CASE WHEN d.is_home_offense THEN p.starting_away_score ELSE p.starting_home_score END AS start_defense_score, 106 | CASE WHEN d.is_home_offense THEN p.ending_home_score ELSE p.ending_away_score END AS end_offense_score, 107 | CASE WHEN d.is_home_offense THEN p.ending_away_score ELSE p.ending_home_score END AS end_defense_score 108 | FROM drives AS d 109 | INNER JOIN points AS p ON d.id = p.id 110 | `, params); 111 | 112 | for (let drive of drives) { 113 | if (!drive.start_time.minutes) { 114 | drive.start_time.minutes = 0; 115 | } 116 | 117 | if (!drive.start_time.seconds) { 118 | drive.start_time.seconds = 0; 119 | } 120 | 121 | if (!drive.end_time.minutes) { 122 | drive.end_time.minutes = 0; 123 | } 124 | 125 | if (!drive.end_time.seconds) { 126 | drive.end_time.seconds = 0; 127 | } 128 | 129 | if (!drive.elapsed.minutes) { 130 | drive.elapsed.minutes = 0; 131 | } 132 | 133 | if (!drive.elapsed.seconds) { 134 | drive.elapsed.seconds = 0; 135 | } 136 | } 137 | 138 | return drives; 139 | } 140 | 141 | const getMedia = async (year, seasonType, week, team, conference, mediaType, classification) => { 142 | const filters = []; 143 | const params = []; 144 | let index = 1; 145 | 146 | if (year) { 147 | filters.push(`g.season = $${index}`); 148 | params.push(year); 149 | index++; 150 | } 151 | 152 | if (seasonType && seasonType.toLowerCase() !== 'both') { 153 | filters.push(`g.season_type = '${seasonType}'`); 154 | params.push(seasonType); 155 | index++; 156 | } 157 | 158 | if (week) { 159 | filters.push(`g.week = $${index}`); 160 | params.push(week); 161 | index++ 162 | } 163 | 164 | if (team) { 165 | filters.push(`(LOWER(home.school) = LOWER($${index}) OR LOWER(away.school) = LOWER($${index}))`); 166 | params.push(team); 167 | index++; 168 | } 169 | 170 | if (conference) { 171 | filters.push(`(LOWER(hc.abbreviation) = LOWER($${index}) OR LOWER(ac.abbreviation) = LOWER($${index}))`); 172 | params.push(conference); 173 | index++; 174 | } 175 | 176 | if (mediaType) { 177 | filters.push(`gm.media_type = $${index}`); 178 | params.push(mediaType.toLowerCase()); 179 | index++; 180 | } 181 | 182 | if (classification && ['fbs', 'fcs', 'ii', 'iii'].includes(classification.toLowerCase())) { 183 | filters.push(`(hc.division = $${index} OR ac.division = $${index})`); 184 | params.push(classification.toLowerCase()); 185 | index++; 186 | } 187 | 188 | const filter = 'WHERE ' + filters.join(' AND '); 189 | 190 | const results = await db.any(` 191 | SELECT g.id, g.season, g.week, g.season_type, g.start_date, g.start_time_tbd, home.school AS home_school, hc.name AS home_conference, away.school AS away_school, ac.name AS away_conference, gm.media_type, gm.name AS outlet 192 | FROM game AS g 193 | INNER JOIN game_media AS gm ON g.id = gm.game_id 194 | INNER JOIN game_team AS home_team ON g.id = home_team.game_id AND home_team.home_away = 'home' 195 | INNER JOIN team AS home ON home_team.team_id = home.id 196 | LEFT JOIN conference_team AS hct ON home.id = hct.team_id AND hct.start_year <= g.season AND (hct.end_year IS NULL OR hct.end_year >= g.season) 197 | LEFT JOIN conference AS hc ON hct.conference_id = hc.id 198 | INNER JOIN game_team AS away_team ON g.id = away_team.game_id AND away_team.home_away = 'away' 199 | INNER JOIN team AS away ON away_team.team_id = away.id 200 | LEFT JOIN conference_team AS act ON away.id = act.team_id AND act.start_year <= g.season AND (act.end_year IS NULL OR act.end_year >= g.season) 201 | LEFT JOIN conference AS ac ON act.conference_id = ac.id 202 | ${filter} 203 | `, params); 204 | 205 | return results.map((r) => ({ 206 | id: r.id, 207 | season: r.season, 208 | week: r.week, 209 | seasonType: r.season_type, 210 | startTime: r.start_date, 211 | isStartTimeTBD: r.start_time_tbd, 212 | homeTeam: r.home_school, 213 | homeConference: r.home_conference, 214 | awayTeam: r.away_school, 215 | awayConference: r.away_conference, 216 | mediaType: r.media_type, 217 | outlet: r.outlet 218 | })); 219 | }; 220 | 221 | const getCalendar = async (year) => { 222 | const weeks = await db.any(` 223 | SELECT g.week, g.season_type, MIN(g.start_date) AS first_game_start, MAX(g.start_date) AS last_game_start 224 | FROM game AS g 225 | WHERE g.season = $1 226 | GROUP BY g.week, g.season_type 227 | ORDER BY g.season_type, g.week 228 | `, [year]); 229 | 230 | return weeks.map(w => ({ 231 | season: year, 232 | week: w.week, 233 | seasonType: w.season_type, 234 | firstGameStart: w.first_game_start, 235 | lastGameStart: w.last_game_start 236 | })); 237 | }; 238 | 239 | const getWeather = async (year, seasonType, week, team, conference, classification) => { 240 | const filters = []; 241 | const params = []; 242 | let index = 1; 243 | 244 | if (year) { 245 | filters.push(`g.season = $${index}`); 246 | params.push(year); 247 | index++; 248 | } 249 | 250 | if (seasonType && seasonType.toLowerCase() !== 'both') { 251 | filters.push(`g.season_type = '${seasonType}'`); 252 | params.push(seasonType); 253 | index++; 254 | } 255 | 256 | if (week) { 257 | filters.push(`g.week = $${index}`); 258 | params.push(week); 259 | index++ 260 | } 261 | 262 | if (team) { 263 | filters.push(`(LOWER(home.school) = LOWER($${index}) OR LOWER(away.school) = LOWER($${index}))`); 264 | params.push(team); 265 | index++; 266 | } 267 | 268 | if (conference) { 269 | filters.push(`(LOWER(hc.abbreviation) = LOWER($${index}) OR LOWER(ac.abbreviation) = LOWER($${index}))`); 270 | params.push(conference); 271 | index++; 272 | } 273 | 274 | if (classification && ['fbs', 'fcs', 'ii', 'iii'].includes(classification.toLowerCase())) { 275 | filters.push(`(hc.division = $${index} OR ac.division = $${index})`); 276 | params.push(classification.toLowerCase()); 277 | index++; 278 | } 279 | 280 | const filter = 'WHERE ' + filters.join(' AND '); 281 | 282 | const results = await db.any(` 283 | SELECT g.id, g.season, g.week, g.season_type, g.start_date, v.dome, home.school AS home_school, hc.name AS home_conference, away.school AS away_school, ac.name AS away_conference, v.id AS venue_id, v.name AS venue, w.temperature, w.dewpoint, w.humidity, w.precipitation, w.snowfall, w.wind_direction, w.wind_speed, w.pressure, w.weather_condition_code, wc.description AS weather_condition 284 | FROM game AS g 285 | INNER JOIN venue AS v ON g.venue_id = v.id 286 | INNER JOIN game_weather AS w ON g.id = w.game_id 287 | INNER JOIN game_team AS home_team ON g.id = home_team.game_id AND home_team.home_away = 'home' 288 | INNER JOIN team AS home ON home_team.team_id = home.id 289 | LEFT JOIN conference_team AS hct ON home.id = hct.team_id AND hct.start_year <= g.season AND (hct.end_year IS NULL OR hct.end_year >= g.season) 290 | LEFT JOIN conference AS hc ON hct.conference_id = hc.id 291 | INNER JOIN game_team AS away_team ON g.id = away_team.game_id AND away_team.home_away = 'away' 292 | INNER JOIN team AS away ON away_team.team_id = away.id 293 | LEFT JOIN conference_team AS act ON away.id = act.team_id AND act.start_year <= g.season AND (act.end_year IS NULL OR act.end_year >= g.season) 294 | LEFT JOIN conference AS ac ON act.conference_id = ac.id 295 | LEFT JOIN weather_condition AS wc ON w.weather_condition_code = wc.id 296 | ${filter} 297 | `, params); 298 | 299 | return results.map(r => ({ 300 | id: parseInt(r.id), 301 | season: parseInt(r.season), 302 | week: parseInt(r.week), 303 | seasonType: r.season_type, 304 | startTime: r.start_date, 305 | gameIndoors: r.dome, 306 | homeTeam: r.home_school, 307 | homeConference: r.home_conference, 308 | awayTeam: r.away_school, 309 | awayConference: r.away_conference, 310 | venueId: parseInt(r.venue_id), 311 | venue: r.venue, 312 | temperature: r.temperature ? parseFloat(r.temperature) : null, 313 | dewPoint: r.dew_point ? parseFloat(r.dew_point) : null, 314 | humidity: r.humidity ? parseFloat(r.humidity) : null, 315 | precipitation: parseFloat(r.precipitation), 316 | snowfall: parseFloat(r.snowfall), 317 | windDirection: r.wind_direction ? parseFloat(r.wind_direction) : null, 318 | windSpeed: r.wind_speed ? parseFloat(r.wind_speed) : null, 319 | pressure: r.pressure ? parseFloat(r.pressure) : null, 320 | weatherConditionCode: r.weather_condition_code ? parseInt(r.weather_condition_code) : null, 321 | weatherCondition: r.weather_condition 322 | })); 323 | }; 324 | 325 | const getScoreboard = async (classification = 'fbs', conference) => { 326 | let filters = []; 327 | let filterParams = []; 328 | let filterIndex = 1; 329 | 330 | filterParams.push(classification.toLowerCase()); 331 | filters.push(`(c.division = $${filterIndex} OR c2.division = $${filterIndex})`); 332 | filterIndex++; 333 | 334 | if (conference) { 335 | filterParams.push(conference.toLowerCase()); 336 | filters.push(`(LOWER(c.abbreviation) = $${filterIndex} OR LOWER(c2.abbreviation) = $${filterIndex})`); 337 | filterIndex++; 338 | } 339 | 340 | let filterClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ''; 341 | 342 | let scoreboard = await db.any(` 343 | WITH this_week AS ( 344 | SELECT DISTINCT g.id, g.season, g.season_type, g.week 345 | FROM game AS g 346 | INNER JOIN game_team AS gt ON g.id = gt.game_id 347 | INNER JOIN current_conferences AS cc ON gt.team_id = cc.team_id AND cc.classification = $1 348 | WHERE g.start_date > (now() - interval '2d') 349 | ORDER BY g.season, g.season_type, g.week 350 | LIMIT 1 351 | 352 | ) 353 | SELECT g.id, 354 | g.start_date AT TIME ZONE 'UTC' AS start_date, 355 | g.start_time_tbd, 356 | g.status, 357 | g.neutral_site, 358 | g.conference_game, 359 | v.name AS venue, 360 | v.city, 361 | v.state, 362 | t.id AS home_id, 363 | t.display_name AS home_team, 364 | c.division AS home_classification, 365 | c.name AS home_conference, 366 | CASE WHEN g.status = 'completed' THEN gt.points ELSE g.current_home_score END AS home_points, 367 | t2.id AS away_id, 368 | t2.display_name AS away_team, 369 | c2.name AS away_conference, 370 | c2.division AS away_classification, 371 | CASE WHEN g.status = 'completed' THEN gt2.points ELSE g.current_away_score END AS away_points, 372 | g.current_period, 373 | CAST(g.current_clock AS CHARACTER VARYING) AS current_clock, 374 | g.current_situation, 375 | g.current_possession, 376 | COALESCE(gm.name, gm2.name) AS tv, 377 | gw.temperature, 378 | gw.wind_speed, 379 | gw.wind_direction, 380 | wc.description AS weather_description, 381 | gl.spread, 382 | gl.over_under, 383 | gl.moneyline_home, 384 | gl.moneyline_away 385 | FROM game AS g 386 | INNER JOIN this_week AS tw ON g.season = tw.season AND g.week = tw.week AND g.season_type = tw.season_type 387 | INNER JOIN game_team AS gt ON g.id = gt.game_id AND gt.home_away = 'home' 388 | INNER JOIN team AS t ON gt.team_id = t.id 389 | INNER JOIN game_team AS gt2 ON g.id = gt2.game_id AND gt.id <> gt2.id 390 | INNER JOIN team AS t2 ON gt2.team_id = t2.id 391 | INNER JOIN venue AS v ON g.venue_id = v.id 392 | LEFT JOIN conference_team AS ct ON t.id = ct.team_id AND ct.end_year IS NULL 393 | LEFT JOIN conference AS c ON ct.conference_id = c.id 394 | LEFT JOIN conference_team AS ct2 ON t2.id = ct2.team_id AND ct2.end_year IS NULL 395 | LEFT JOIN conference AS c2 ON ct2.conference_id = c2.id 396 | LEFT JOIN game_media AS gm ON g.id = gm.game_id AND gm.media_type = 'tv' 397 | LEFT JOIN game_media AS gm2 ON g.id = gm2.game_id AND gm.id <> gm2.id AND gm2.media_type = 'web' 398 | LEFT JOIN game_weather AS gw ON g.id = gw.game_id 399 | LEFT JOIN weather_condition AS wc ON gw.weather_condition_code = wc.id 400 | LEFT JOIN game_lines AS gl ON g.id = gl.game_id AND gl.lines_provider_id = 999999 401 | ${filterClause} 402 | ORDER BY g.start_date 403 | `, filterParams); 404 | 405 | return scoreboard.map(s => ({ 406 | id: parseInt(s.id), 407 | startDate: s.start_date, 408 | startTimeTBD: s.start_time_tbd, 409 | tv: s.tv, 410 | neutralSite: s.neutral_site, 411 | conferenceGame: s.conference_game, 412 | status: s.status, 413 | period: parseInt(s.current_period), 414 | clock: s.current_clock, 415 | situation: s.current_situation, 416 | possession: s.current_possession, 417 | venue: { 418 | name: s.venue, 419 | city: s.city, 420 | state: s.state 421 | }, 422 | homeTeam: { 423 | id: s.home_id, 424 | name: s.home_team, 425 | conference: s.home_conference, 426 | classification: s.home_classification, 427 | points: s.home_points 428 | }, 429 | awayTeam: { 430 | id: s.away_id, 431 | name: s.away_team, 432 | conference: s.away_conference, 433 | classification: s.away_classification, 434 | points: s.away_points 435 | }, 436 | weather: { 437 | temperature: s.temperature, 438 | description: s.weather_description, 439 | windSpeed: s.wind_speed, 440 | windDirection: s.wind_direction 441 | }, 442 | betting: { 443 | spread: s.spread, 444 | overUnder: s.over_under, 445 | homeMoneyline: s.moneyline_home, 446 | awayMoneyline: s.moneyline_away 447 | } 448 | })); 449 | }; 450 | 451 | return { 452 | getDrives, 453 | getMedia, 454 | getCalendar, 455 | getWeather, 456 | getScoreboard 457 | }; 458 | }; --------------------------------------------------------------------------------