├── .eslintignore ├── .eslintrc.yml ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── README.md ├── deploy.sh ├── deploy_rsa.enc ├── desktop.ini ├── docker-compose.yml ├── mysql ├── Dockerfile └── init.sql ├── package-lock.json ├── package.json ├── scripts ├── deploy.sh └── deploy_helper.sh ├── src ├── api │ ├── auth │ │ ├── auth.router.ts │ │ ├── index.ts │ │ ├── strategies │ │ │ └── local.ts │ │ ├── tests │ │ │ ├── login-local.spec.ts │ │ │ ├── register-local.spec.ts │ │ │ └── verify-local.spec.ts │ │ └── verify.ts │ ├── index.ts │ ├── push-subscriptions │ │ ├── create-push-subscription.ts │ │ ├── get-push-subscription.ts │ │ ├── index.ts │ │ ├── patch-push-subscription.ts │ │ └── tests │ │ │ ├── create-push-subscription.spec.ts │ │ │ ├── get-push-subscription.spec.ts │ │ │ ├── patch-push-subscription.spec.ts │ │ │ └── vapid-public-key.spec.ts │ └── schedules │ │ ├── create-schedule.ts │ │ ├── delete-schedule.ts │ │ ├── get-schedules.ts │ │ ├── index.ts │ │ ├── patch-schedule.ts │ │ └── tests │ │ ├── create-schedule.spec.ts │ │ ├── delete-schedule.spec.ts │ │ ├── get-schedules.spec.ts │ │ └── patch-schedule.spec.ts ├── app.ts ├── index.ts ├── models │ ├── db.ts │ ├── index.ts │ ├── notifications │ │ ├── create-necessary.ts │ │ ├── create.ts │ │ ├── destroy.ts │ │ ├── find.ts │ │ ├── index.ts │ │ ├── init.ts │ │ ├── notification.ts │ │ ├── test │ │ │ ├── create-necessary-notifications.spec.ts │ │ │ └── find-notifications.spec.ts │ │ └── update.ts │ ├── push-subscriptions │ │ ├── create.ts │ │ ├── destroy.ts │ │ ├── exists.ts │ │ ├── find.ts │ │ ├── index.ts │ │ ├── init.ts │ │ ├── push-subscription.ts │ │ ├── sanitize.ts │ │ ├── tests │ │ │ └── destroy-push-subscription.spec.ts │ │ └── update.ts │ ├── schedule-subscriptions │ │ ├── create.ts │ │ ├── find-schedules.ts │ │ ├── find.ts │ │ ├── index.ts │ │ ├── init.ts │ │ ├── schedule-subscription.ts │ │ └── update.ts │ ├── schedules │ │ ├── create.ts │ │ ├── destroy.ts │ │ ├── find.ts │ │ ├── index.ts │ │ ├── init.ts │ │ ├── schedule.ts │ │ └── update.ts │ ├── users │ │ ├── create.ts │ │ ├── exists.ts │ │ ├── find.ts │ │ ├── index.ts │ │ ├── init.ts │ │ ├── sanitize.ts │ │ └── user.ts │ └── util │ │ ├── create-id.ts │ │ └── x-or.d.ts ├── notifications │ ├── index.ts │ ├── on-the-minute.ts │ └── send-notifications.ts ├── test-utils │ ├── auth.ts │ ├── index.ts │ ├── push-subscriptions.ts │ └── schedules.ts ├── typings │ └── express.d.ts └── util.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | **/*.spec.ts -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - alloy 4 | - alloy/typescript 5 | env: 6 | mocha: true 7 | jquery: true 8 | node: true 9 | globals: 10 | expect: true 11 | rules: 12 | no-invalid-this: 0 13 | eol-last: 14 | - error 15 | - always 16 | '@typescript-eslint/prefer-optional-chain': 0 17 | guard-for-in: 0 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: tylergrinn 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # local env files 4 | .env.local 5 | .env.*.local 6 | *.env 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | *.sw? 20 | debug.log 21 | 22 | /bin 23 | /deploy_rsa 24 | /deploy_rsa.pub 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: xenial 3 | language: node_js 4 | node_js: lts/* 5 | services: 6 | - mysql 7 | addons: 8 | ssh_known_hosts: cronpush.tygr.info 9 | 10 | # SSH Setup 11 | before_deploy: 12 | - openssl aes-256-cbc 13 | -K $encrypted_db2095f63ba3_key 14 | -iv $encrypted_db2095f63ba3_iv 15 | -in deploy_rsa.enc 16 | -out /tmp/deploy_rsa 17 | -d 18 | - eval "$(ssh-agent -s)" 19 | - chmod 600 /tmp/deploy_rsa 20 | - ssh-add /tmp/deploy_rsa 21 | 22 | env: 23 | global: 24 | # Test setup 25 | - MYSQL_HOST=127.0.0.1 26 | - MYSQL_PORT=3306 27 | - MYSQL_USER=root 28 | - MYSQL_PASSWORD= 29 | - MYSQL_TEST_DB=cronpush_test 30 | - JWT_SECRET=shhh 31 | 32 | before_script: 33 | - mysql -e 'CREATE DATABASE `cronpush_test`;' 34 | 35 | jobs: 36 | include: 37 | - stage: test 38 | script: npm run quality:check 39 | - script: 40 | - npm run build 41 | - npm test 42 | deploy: 43 | edge: true 44 | on: 45 | branch: main 46 | provider: script 47 | script: bash scripts/deploy.sh 48 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": ["/**"], 12 | "type": "pwa-node" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.background": "#233300", 4 | "titleBar.activeBackground": "#304701", 5 | "titleBar.activeForeground": "#F2FFD6" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cron Push 2 | 3 | ## Quickstart 4 | 5 | 1. `docker-compose up -d`\ 6 | Start the mysql database 7 | See `docker-compose` section 8 | 9 | 2. `npm i`\ 10 | Install node dependencies 11 | 12 | 3. `npm start`\ 13 | Build the project and watch for changes. After each build, the lint and 14 | serve scripts will be run. 15 | 16 | ## Environment 17 | 18 | 1. Node environment variables: `src/environment.ts` 19 | - You may create a `.env` file in the project root directory to modify defaults 20 | - Shell environment variables **will** be passed on to node and will overwrite 21 | variables listed in the `.env` file 22 | 2. MySQL environment variables: `mysql/Dockerfile` 23 | - Shell environment variables **will not** be passed on to MySQL 24 | 25 | ## docker-compose 26 | 27 | - docker-compose up -d\ 28 | Start the mysql container in the background 29 | 30 | - docker-compose stop\ 31 | Stop the mysql container 32 | 33 | - docker-compose down\ 34 | Destroy the mysql container. Any changes made to the database will be lost 35 | 36 | - docker-compose up --build -d\ 37 | If any changes are made to the `docker-compose.yml` file or the `mysql` folder, add 38 | the `--build` flag to the command when starting to incorporate the changes 39 | 40 | ## Build 41 | 42 | ``` 43 | npm run build 44 | ``` 45 | 46 | ## Serve 47 | 48 | ``` 49 | npm run serve 50 | ``` 51 | 52 | ## Tests 53 | 54 | Tests use the database specified by the environment variable MYSQL_TEST_DB . The database will be cleared completely for each test 55 | 56 | ### Standalone 57 | 58 | ``` 59 | npm test 60 | ``` 61 | 62 | ### During development 63 | 64 | `npm start` will start the tests in debug mode.\ 65 | Attach a debugger to the default node debug port, `9229`, and continue past the initial breakpoint to run the tests.\ 66 | The test will be restarted when the code changes or by typing `rs` into the terminal to restart manually. 67 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd cron-push 4 | npm ci 5 | npm test 6 | sudo systemctl restart cron-push 7 | -------------------------------------------------------------------------------- /deploy_rsa.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amygrinn/cronpush/202fba9cc984bf596b4e5621cd86d5d7fe8a6e39/deploy_rsa.enc -------------------------------------------------------------------------------- /desktop.ini: -------------------------------------------------------------------------------- 1 | [.ShellClassInfo] 2 | IconResource=C:\Users\tyler\Desktop\cron-push-web\public\favicon.ico,0 3 | [ViewState] 4 | Mode= 5 | Vid= 6 | FolderType=Generic 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | mysql: 4 | build: 5 | context: ./mysql 6 | ports: [3306:3306] 7 | -------------------------------------------------------------------------------- /mysql/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mysql:5 2 | 3 | ENV MYSQL_ROOT_PASSWORD password 4 | COPY init.sql /docker-entrypoint-initdb.d 5 | -------------------------------------------------------------------------------- /mysql/init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE IF NOT EXISTS cronpush; 2 | CREATE DATABASE IF NOT EXISTS cronpush_test; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron-push", 3 | "version": "0.1.2", 4 | "private": true, 5 | "scripts": { 6 | "start": "run-p --silent watch build:w", 7 | "build": "tsc", 8 | "build:w": "tsc --watch", 9 | "watch": "nodemon --quiet --watch bin --on-change-only --exec \"npm run --silent watch:task\"", 10 | "watch:task": "run-p --silent --print-label lint test:debug serve", 11 | "lint:check": "eslint . --ext .js,.jsx,.ts,.tsx", 12 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 13 | "lint": "npm run lint:fix", 14 | "prettier:check": "prettier -c .", 15 | "prettier:fix": "prettier --write .", 16 | "prettier": "npm run prettier:fix", 17 | "quality:check": "run-s --print-label --silent lint:check prettier:check", 18 | "quality:fix": "run-p --print-label --silent lint:fix prettier:fix", 19 | "quality": "npm run quality:fix", 20 | "serve": "node --require dotenv/config --enable-source-maps bin", 21 | "serve:debug": "node --inspect --require dotenv/config --enable-source-maps bin", 22 | "test": "cross-env NODE_ENV=test mocha", 23 | "test:debug": "npm test -- --inspect --inspect-publish-uid=http --inspect-brk" 24 | }, 25 | "mocha": { 26 | "timeout": 5000, 27 | "exit": true, 28 | "require": [ 29 | "dotenv/config" 30 | ], 31 | "spec": "bin/**/*.spec.js" 32 | }, 33 | "dependencies": { 34 | "bcrypt": "^5.0.0", 35 | "cron-parser": "^2.13.0", 36 | "cronstrue": "^1.100.0", 37 | "cross-env": "^7.0.0", 38 | "dotenv": "^8.2.0", 39 | "express": "^4.17.1", 40 | "jsonwebtoken": "^8.5.1", 41 | "mocha": "^8.1.3", 42 | "mysql": "^2.18.1", 43 | "mysql2": "^2.2.5", 44 | "passport": "^0.4.1", 45 | "passport-jwt": "^4.0.0", 46 | "passport-local": "^1.0.0", 47 | "supertest": "^4.0.2", 48 | "ts-node": "^9.0.0", 49 | "uuid": "^8.3.0", 50 | "web-push": "^3.4.3" 51 | }, 52 | "devDependencies": { 53 | "@types/bcrypt": "^3.0.0", 54 | "@types/chai": "^4.2.5", 55 | "@types/express": "^4.17.8", 56 | "@types/jsonwebtoken": "^8.3.5", 57 | "@types/luxon": "^1.25.0", 58 | "@types/mocha": "^5.2.4", 59 | "@types/mysql": "^2.15.15", 60 | "@types/passport": "^1.0.2", 61 | "@types/passport-jwt": "^3.0.3", 62 | "@types/passport-local": "^1.0.33", 63 | "@types/proxyquire": "^1.3.28", 64 | "@types/sinon": "^9.0.6", 65 | "@types/sinon-chai": "^3.2.5", 66 | "@types/supertest": "^2.0.10", 67 | "@types/uuid": "^8.3.0", 68 | "@types/web-push": "^3.3.0", 69 | "@typescript-eslint/eslint-plugin": "^3.10.1", 70 | "@typescript-eslint/parser": "^4.3.0", 71 | "babel-eslint": "^10.1.0", 72 | "chai": "^4.2.0", 73 | "eslint": "^7.10.0", 74 | "eslint-config-alloy": "^3.8.0", 75 | "eslint-plugin-chai-friendly": "^0.6.0", 76 | "eslint-watch": "^7.0.0", 77 | "luxon": "^1.25.0", 78 | "nodemon": "^2.0.1", 79 | "npm-run-all": "^4.1.5", 80 | "prettier": "^2.1.2", 81 | "prettier-plugin-organize-imports": "^1.1.1", 82 | "proxyquire": "^2.1.3", 83 | "sinon": "^9.0.3", 84 | "sinon-chai": "^3.5.0", 85 | "typescript": "^4.0.3" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rsync --recursive --delete \ 4 | $TRAVIS_BUILD_DIR/bin \ 5 | $TRAVIS_BUILD_DIR/package.json \ 6 | $TRAVIS_BUILD_DIR/package-lock.json \ 7 | cron-push@cronpush.tygr.info:cron-push 8 | 9 | ssh cron-push@cronpush.tygr.info 'bash -sl' < $TRAVIS_BUILD_DIR/scripts/deploy_helper.sh 10 | -------------------------------------------------------------------------------- /scripts/deploy_helper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd cron-push 4 | npm ci 5 | npm test 6 | sudo systemctl restart cron-push 7 | -------------------------------------------------------------------------------- /src/api/auth/auth.router.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler, RequestHandler, Router } from 'express'; 2 | import passport from 'passport'; 3 | import { users } from '../../models'; 4 | import verify from './verify'; 5 | 6 | const router = Router(); 7 | export default router; 8 | 9 | router.post( 10 | '/register', 11 | passport.authenticate(['register-local'], { 12 | session: false, 13 | failWithError: true, 14 | }), 15 | ((req, res) => res.json(users.sanitize(req.user!))) as RequestHandler, 16 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, max-params 17 | ((error, req, res, next) => 18 | res.status(400).json({ error })) as ErrorRequestHandler 19 | ); 20 | 21 | router.post( 22 | '/login', 23 | passport.authenticate(['login-local'], { 24 | session: false, 25 | failWithError: true, 26 | }), 27 | ((req, res) => res.json(users.sanitize(req.user!))) as RequestHandler, 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, max-params 29 | ((error, req, res, next) => 30 | res.status(400).json({ error })) as ErrorRequestHandler 31 | ); 32 | 33 | router.get( 34 | '/verify', 35 | verify(true), 36 | ((req, res) => res.json(users.sanitize(req.user!))) as RequestHandler, 37 | // eslint-disable-next-line @typescript-eslint/no-unused-vars, max-params 38 | ((error, req, res, next) => 39 | res.status(400).json({ error })) as ErrorRequestHandler 40 | ); 41 | -------------------------------------------------------------------------------- /src/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import router from './auth.router'; 3 | import * as local from './strategies/local'; 4 | import verify from './verify'; 5 | 6 | passport.use('register-local', local.register); 7 | passport.use('login-local', local.login); 8 | passport.use('jwt', local.jwt); 9 | 10 | export { router, verify }; 11 | -------------------------------------------------------------------------------- /src/api/auth/strategies/local.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import jsonwebtoken from 'jsonwebtoken'; 3 | import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt'; 4 | import { Strategy as LocalStrategy } from 'passport-local'; 5 | import { users } from '../../../models'; 6 | 7 | const signJwt = (payload: any) => 8 | new Promise((resolve, reject) => { 9 | jsonwebtoken.sign( 10 | payload, 11 | process.env.JWT_SECRET!, 12 | (err?: Error | null, token?: string) => 13 | err ? reject(err) : resolve(token) 14 | ); 15 | }); 16 | 17 | export const register = new LocalStrategy(async (username, password, done) => { 18 | if (await users.exists(username)) { 19 | done('User already exists'); 20 | } else { 21 | const hash = await bcrypt.hash(password, 10); 22 | const user = await users.create({ username, password: hash }); 23 | user.token = await signJwt(users.sanitize(user)); 24 | done(null, user); 25 | } 26 | }); 27 | 28 | export const login = new LocalStrategy(async (username, password, done) => { 29 | const user = await users.find({ username }); 30 | 31 | if (!user) { 32 | done('User does not exist'); 33 | } else if (!(await bcrypt.compare(password, user.password))) { 34 | done('Incorrect password'); 35 | } else { 36 | user.token = await signJwt(users.sanitize(user)); 37 | done(null, user); 38 | } 39 | }); 40 | 41 | export const jwt = new JwtStrategy( 42 | { 43 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 44 | secretOrKey: process.env.JWT_SECRET, 45 | }, 46 | async ({ id }, done) => { 47 | const user = await users.find({ id }); 48 | if (!user) { 49 | done(new Error('User does not exist')); 50 | } 51 | done(null, user); 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /src/api/auth/tests/login-local.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth } from '../../../test-utils'; 6 | 7 | describe('Login Local', () => { 8 | before(() => init().then(Auth.init)); 9 | 10 | it('Logs in to test account', () => 11 | request(app) 12 | .post('/auth/login') 13 | .send({ username: Auth.USERNAME, password: Auth.PASSWORD }) 14 | .expect(200) 15 | .then((response) => { 16 | expect(response.body.id).to.not.be.null; 17 | expect(response.body.username).to.equal('test'); 18 | expect(response.body.token).to.not.be.null; 19 | })); 20 | 21 | it('Cannot login with wrong password', () => 22 | request(app) 23 | .post('/auth/login') 24 | .send({ username: Auth.USERNAME, password: 'wrong' }) 25 | .expect(400)); 26 | }); 27 | -------------------------------------------------------------------------------- /src/api/auth/tests/register-local.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | 6 | describe('Register Local', () => { 7 | before(init); 8 | 9 | it('Creates an account', () => 10 | request(app) 11 | .post('/auth/register') 12 | .send({ username: 'test', password: 'test' }) 13 | .expect(200) 14 | .then((response) => { 15 | expect(response.body.username).to.equal('test'); 16 | expect(response.body.token).to.not.be.null; 17 | expect(response.body.id).to.not.be.null; 18 | })); 19 | 20 | it('Cannot create an account with existing username', () => 21 | request(app) 22 | .post('/auth/register') 23 | .send({ username: 'test', password: 'test' }) 24 | .expect(400)); 25 | }); 26 | -------------------------------------------------------------------------------- /src/api/auth/tests/verify-local.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth } from '../../../test-utils'; 6 | 7 | describe('Verify by JWT', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | ); 16 | 17 | it('Get user information using token', () => 18 | request(app) 19 | .get('/auth/verify') 20 | .set('Authorization', `Bearer ${token}`) 21 | .expect(200) 22 | .then((response) => { 23 | expect(response.body.id).to.not.be.null; 24 | expect(response.body.username).to.equal('test'); 25 | })); 26 | }); 27 | -------------------------------------------------------------------------------- /src/api/auth/verify.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import passport from 'passport'; 3 | 4 | export default (required: boolean): RequestHandler => (req, res, next) => 5 | passport.authenticate(['jwt'], { session: false }, (error, user) => { 6 | if (required && error) { 7 | return res.status(401).json({ error }); 8 | } 9 | 10 | req.user = user; 11 | next(); 12 | })(req, res, next); 13 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as auth from './auth'; 2 | import pushRouter from './push-subscriptions'; 3 | import schedulesRouter from './schedules'; 4 | 5 | export { auth, pushRouter, schedulesRouter }; 6 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/create-push-subscription.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, Response } from 'express'; 2 | import { pushSubscriptions } from '../../models'; 3 | 4 | const createPushSubscription: RequestHandler = async ( 5 | req, 6 | res 7 | ): Promise => { 8 | if ( 9 | !req.body.endpoint || 10 | !req.body.keys || 11 | !req.body.keys.p256dh || 12 | !req.body.keys.auth || 13 | !req.body.timeZone 14 | ) { 15 | return res.status(400).json({ error: 'Bad request' }); 16 | } 17 | 18 | const pushSubscription: pushSubscriptions.PushSubscription = { 19 | endpoint: req.body.endpoint, 20 | keys: { 21 | p256dh: req.body.keys.p256dh, 22 | auth: req.body.keys.auth, 23 | }, 24 | timeZone: req.body.timeZone, 25 | userId: req.user ? req.user.id : undefined, 26 | enabled: req.body.enabled !== false, 27 | }; 28 | 29 | // let pushSubscription = await pushSubscriptions.find(req.body.endpoint); 30 | if (await pushSubscriptions.exists(req.body.endpoint)) { 31 | return res.status(400).json({ error: 'Push subscription already exists' }); 32 | } 33 | 34 | await pushSubscriptions.create(pushSubscription); 35 | 36 | return res.json(pushSubscriptions.sanitize(pushSubscription)); 37 | }; 38 | 39 | export default createPushSubscription; 40 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/get-push-subscription.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, Response } from 'express'; 2 | import { pushSubscriptions } from '../../models'; 3 | 4 | const getPushSubscription: RequestHandler = async ( 5 | req, 6 | res 7 | ): Promise => { 8 | if (!req.query.endpoint || typeof req.query.endpoint !== 'string') { 9 | return res.status(400).json({ error: 'Bad Request' }); 10 | } 11 | 12 | const pushSubscription = await pushSubscriptions.find(req.query.endpoint); 13 | 14 | if (!pushSubscription) { 15 | return res.status(404).json({ error: 'Subscription does not exist' }); 16 | } 17 | 18 | return res.json(pushSubscriptions.sanitize(pushSubscription)); 19 | }; 20 | 21 | export default getPushSubscription; 22 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import createPushSubscription from './create-push-subscription'; 3 | import getPushSubscription from './get-push-subscription'; 4 | import patchPushSubscription from './patch-push-subscription'; 5 | 6 | const pushRouter = Router(); 7 | 8 | pushRouter.get('/vapid-public-key', (req, res) => { 9 | res.set('Content-Type', 'text/plain'); 10 | return res.send(process.env.VAPID_PUBLIC_KEY as string); 11 | }); 12 | pushRouter.get('/', getPushSubscription); 13 | pushRouter.post('/', createPushSubscription); 14 | pushRouter.patch('/', patchPushSubscription); 15 | 16 | export default pushRouter; 17 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/patch-push-subscription.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, Response } from 'express'; 2 | import { pushSubscriptions } from '../../models'; 3 | 4 | const patchPushSubscription: RequestHandler = async ( 5 | req, 6 | res 7 | ): Promise => { 8 | if (!req.body.endpoint) { 9 | return res.status(400).json({ error: 'Bad Request' }); 10 | } 11 | 12 | const pushSubscription = await pushSubscriptions.find(req.body.endpoint); 13 | 14 | if (!pushSubscription) { 15 | return res.status(404).json({ error: 'Push subscription not found' }); 16 | } 17 | 18 | if (!pushSubscription.userId && req.user) { 19 | pushSubscription.userId = req.user.id; 20 | } 21 | 22 | const authorized = 23 | !pushSubscription.userId || 24 | (req.user && req.user.id === pushSubscription.userId); 25 | 26 | if (authorized) { 27 | if ('enabled' in req.body) pushSubscription.enabled = req.body.enabled; 28 | pushSubscription.timeZone = req.body.timeZone || pushSubscription.timeZone; 29 | } else if (req.body.enabled === false) { 30 | // Always allow turning off notifications even when signed out 31 | pushSubscription.enabled = false; 32 | } else { 33 | return res.status(403).json({ error: 'Not authorized' }); 34 | } 35 | 36 | await pushSubscriptions.update(pushSubscription); 37 | return res.json(pushSubscriptions.sanitize(pushSubscription)); 38 | }; 39 | 40 | export default patchPushSubscription; 41 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/tests/create-push-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth } from '../../../test-utils'; 6 | 7 | describe('Create push subscription', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | ); 16 | 17 | it('Creates a new subscription w/o login', () => 18 | request(app) 19 | .post('/push') 20 | .send({ 21 | endpoint: 'endpoint', 22 | keys: { 23 | p256dh: 'test-p256dh', 24 | auth: 'test-auth', 25 | }, 26 | timeZone: 'America/New_York', 27 | }) 28 | .expect(200) 29 | .then((response) => { 30 | expect(response.body.id).to.not.be.null; 31 | expect(response.body.endpoint).to.equal('endpoint'); 32 | expect(response.body.timeZone).to.equal('America/New_York'); 33 | expect(response.body.enabled).to.be.true; 34 | })); 35 | 36 | it('Creates a new subscription w/ login', () => 37 | request(app) 38 | .post('/push') 39 | .set('Authorization', `Bearer ${token}`) 40 | .send({ 41 | endpoint: 'user-endpoint', 42 | keys: { 43 | p256dh: 'test-p256dh', 44 | auth: 'test-auth', 45 | }, 46 | timeZone: 'America/New_York', 47 | }) 48 | .expect(200) 49 | .then((response) => { 50 | expect(response.body.id).to.not.be.null; 51 | expect(response.body.endpoint).to.equal('user-endpoint'); 52 | expect(response.body.timeZone).to.equal('America/New_York'); 53 | expect(response.body.enabled).to.be.true; 54 | expect(response.body.user).to.not.be.null; 55 | })); 56 | }); 57 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/tests/get-push-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth, PushSubscriptions } from '../../../test-utils'; 6 | 7 | describe('Get push subscription', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | ); 17 | 18 | it('Gets a push subscription w/o user', () => 19 | request(app) 20 | .get('/push') 21 | .query({ endpoint: PushSubscriptions.ENDPOINT }) 22 | .expect(200) 23 | .then((response) => { 24 | expect(response.body.id).to.not.be.null; 25 | expect(response.body.enabled).to.not.be.null; 26 | expect(response.body.endpoint).to.equal(PushSubscriptions.ENDPOINT); 27 | })); 28 | 29 | it('Gets a push subscription w/ user', () => 30 | request(app) 31 | .get('/push') 32 | .query({ endpoint: PushSubscriptions.USER_ENDPOINT }) 33 | .expect(200) 34 | .then((response) => { 35 | expect(response.body.id).to.not.be.null; 36 | expect(response.body.enabled).to.not.be.null; 37 | expect(response.body.endpoint).to.equal( 38 | PushSubscriptions.USER_ENDPOINT 39 | ); 40 | expect(response.body.user).to.not.be.null; 41 | })); 42 | }); 43 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/tests/patch-push-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth, PushSubscriptions } from '../../../test-utils'; 6 | 7 | describe('Patch push subscription', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | ); 17 | 18 | it('Disables a subscription', () => 19 | request(app) 20 | .patch('/push') 21 | .send({ 22 | endpoint: PushSubscriptions.ENDPOINT, 23 | enabled: false, 24 | }) 25 | .expect(200) 26 | .then((response) => { 27 | expect(response.body.endpoint).to.equal(PushSubscriptions.ENDPOINT); 28 | expect(response.body.enabled).to.be.false; 29 | expect(response.body.id).to.not.be.null; 30 | })); 31 | 32 | it('Enables a subscription', () => 33 | request(app) 34 | .patch('/push') 35 | .send({ 36 | endpoint: PushSubscriptions.ENDPOINT, 37 | enabled: true, 38 | }) 39 | .expect(200) 40 | .then((response) => { 41 | expect(response.body.endpoint).to.equal(PushSubscriptions.ENDPOINT); 42 | expect(response.body.enabled).to.be.true; 43 | expect(response.body.id).to.not.be.null; 44 | })); 45 | 46 | it('Adds a user to a subscription', () => 47 | request(app) 48 | .patch('/push') 49 | .set('Authorization', `Bearer ${token}`) 50 | .send({ endpoint: 'endpoint' }) 51 | .expect(200) 52 | .then((response) => { 53 | expect(response.body.user).to.not.be.null; 54 | })); 55 | }); 56 | -------------------------------------------------------------------------------- /src/api/push-subscriptions/tests/vapid-public-key.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | 6 | describe('Get vapid public key', () => { 7 | before(init); 8 | 9 | it('Gets the current key', () => { 10 | process.env.VAPID_PUBLIC_KEY = 'test key!'; 11 | return request(app) 12 | .get('/push/vapid-public-key') 13 | .expect(200) 14 | .then((response) => { 15 | expect(response.text).to.equal('test key!'); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/api/schedules/create-schedule.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { 3 | pushSubscriptions, 4 | schedules, 5 | scheduleSubscriptions, 6 | } from '../../models'; 7 | 8 | const createSchedule: RequestHandler = async (req, res) => { 9 | if ( 10 | !req.body.push || 11 | !req.body.push.endpoint || 12 | !req.body.schedule || 13 | !req.body.schedule.cronExpression || 14 | !req.body.schedule.title 15 | ) { 16 | return res.status(400).json({ error: 'Bad Request' }); 17 | } 18 | 19 | if (!(await pushSubscriptions.exists(req.body.push.endpoint))) { 20 | return res.status(404).json({ error: 'Push subscription does not exist' }); 21 | } 22 | 23 | const schedule = await schedules.create({ 24 | cronExpression: req.body.schedule.cronExpression, 25 | title: req.body.schedule.title, 26 | icon: req.body.schedule.icon, 27 | message: req.body.schedule.message, 28 | userId: req.user && req.user.id, 29 | }); 30 | 31 | schedule.enabled = req.body.enabled !== false; 32 | 33 | await scheduleSubscriptions.create( 34 | req.body.push.endpoint, 35 | schedule.id, 36 | schedule.enabled 37 | ); 38 | 39 | return res.json(schedule); 40 | }; 41 | 42 | export default createSchedule; 43 | -------------------------------------------------------------------------------- /src/api/schedules/delete-schedule.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { schedules } from '../../models'; 3 | 4 | const deleteSchedule: RequestHandler = async (req, res) => { 5 | const schedule = await schedules.find(req.params.scheduleId); 6 | 7 | if (!schedule) { 8 | return res.status(404).json({ error: 'Schedule does not exist' }); 9 | } 10 | 11 | if (schedule.userId && (!req.user || req.user.id !== schedule.userId)) { 12 | return res.status(401).json({ error: 'Unauthorized' }); 13 | } 14 | 15 | await schedules.destroy(req.params.scheduleId); 16 | 17 | return res.sendStatus(200); 18 | }; 19 | 20 | export default deleteSchedule; 21 | -------------------------------------------------------------------------------- /src/api/schedules/get-schedules.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { pushSubscriptions, scheduleSubscriptions } from '../../models'; 3 | 4 | const getSchedules: RequestHandler = async (req, res) => { 5 | if (!req.query.endpoint || typeof req.query.endpoint !== 'string') { 6 | return res.status(400).json({ error: 'Bad Request' }); 7 | } 8 | 9 | const pushSubscription = await pushSubscriptions.find(req.query.endpoint); 10 | 11 | if (!pushSubscription) { 12 | return res.status(404).json({ error: 'Push Subscription does not exist' }); 13 | } 14 | 15 | const schedules = await scheduleSubscriptions.findSchedules({ 16 | endpoint: req.query.endpoint, 17 | userId: req.user ? req.user.id : undefined, 18 | }); 19 | 20 | return res.json({ schedules }); 21 | }; 22 | 23 | export default getSchedules; 24 | -------------------------------------------------------------------------------- /src/api/schedules/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import createSchedule from './create-schedule'; 3 | import deleteSchedule from './delete-schedule'; 4 | import getSchedules from './get-schedules'; 5 | import patchSchedule from './patch-schedule'; 6 | 7 | const schedulesRouter = Router(); 8 | 9 | schedulesRouter.get('/', getSchedules); 10 | schedulesRouter.post('/', createSchedule); 11 | schedulesRouter.patch('/:scheduleId', patchSchedule); 12 | schedulesRouter.delete('/:scheduleId', deleteSchedule); 13 | 14 | export default schedulesRouter; 15 | -------------------------------------------------------------------------------- /src/api/schedules/patch-schedule.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { 3 | notifications, 4 | pushSubscriptions, 5 | schedules, 6 | scheduleSubscriptions, 7 | } from '../..//models'; 8 | import { Schedule } from '../../models/schedules'; 9 | 10 | interface PatchScheduleBody { 11 | schedule: Partial; 12 | push: { endpoint: string }; 13 | enabled: boolean; 14 | } 15 | 16 | const constrain = (body: any): PatchScheduleBody | never => { 17 | // prettier-signore 18 | if ( 19 | !body.push || 20 | !body.push.endpoint || 21 | body.enabled === null || 22 | body.enabled === undefined 23 | ) { 24 | throw new Error('Incorrect body'); 25 | } 26 | 27 | if (!body.schedule) body.schedule = {}; 28 | 29 | return { 30 | schedule: { 31 | cronExpression: body.schedule.cronExpression, 32 | title: body.schedule.title, 33 | icon: body.schedule.icon, 34 | message: body.schedule.message, 35 | userId: body.schedule.userId, 36 | }, 37 | push: { endpoint: body.push.endpoint }, 38 | enabled: !!body.enabled, 39 | }; 40 | }; 41 | 42 | const patchSchedule: RequestHandler = async (req, res) => { 43 | const { scheduleId } = req.params; 44 | 45 | let body: PatchScheduleBody; 46 | try { 47 | body = constrain(req.body); 48 | } catch (err) { 49 | return res.sendStatus(400); 50 | } 51 | 52 | let schedule = await schedules.find(scheduleId); 53 | if (!schedule) return res.sendStatus(404); 54 | 55 | const pushSubscription = await pushSubscriptions.find(body.push.endpoint); 56 | if (!pushSubscription) return res.sendStatus(404); 57 | 58 | const authorized = 59 | !schedule.userId || (req.user && req.user.id === schedule.userId); 60 | 61 | let scheduleSubscription = await scheduleSubscriptions.find( 62 | body.push.endpoint, 63 | scheduleId 64 | ); 65 | 66 | let recreateNotifications = false; 67 | let updateSchedule = false; 68 | let updateScheduleSubscription = false; 69 | 70 | if (authorized) { 71 | if (!scheduleSubscription) { 72 | scheduleSubscription = await scheduleSubscriptions.create( 73 | body.push.endpoint, 74 | scheduleId, 75 | body.enabled 76 | ); 77 | } else if (body.enabled !== scheduleSubscription.enabled) { 78 | scheduleSubscription.enabled = body.enabled; 79 | updateScheduleSubscription = true; 80 | } 81 | 82 | recreateNotifications = 83 | body.schedule.cronExpression !== schedule.cronExpression; 84 | 85 | for (const key in body.schedule) { 86 | const k = key as keyof Schedule; 87 | if (body.schedule[k] !== undefined) { 88 | updateSchedule = true; 89 | schedule[k] = body.schedule[k] as never; 90 | } 91 | } 92 | } else if (!body.enabled && scheduleSubscription) { 93 | // Always allow notifications to be turned off 94 | scheduleSubscription.enabled = false; 95 | recreateNotifications = true; 96 | updateScheduleSubscription = true; 97 | } else { 98 | return res.sendStatus(403); 99 | } 100 | 101 | if (!recreateNotifications && !updateSchedule && !updateScheduleSubscription) 102 | return res.sendStatus(400); // No action taken 103 | 104 | const promises: Promise[] = []; 105 | if (recreateNotifications) promises.push(notifications.destroy(scheduleId)); 106 | if (updateSchedule) promises.push(schedules.update(schedule)); 107 | if (updateScheduleSubscription) 108 | promises.push(scheduleSubscriptions.update(scheduleSubscription)); 109 | 110 | await Promise.all(promises); 111 | 112 | schedule.enabled = pushSubscription.enabled && scheduleSubscription.enabled; 113 | 114 | return res.json(schedule); 115 | }; 116 | 117 | export default patchSchedule; 118 | -------------------------------------------------------------------------------- /src/api/schedules/tests/create-schedule.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth, PushSubscriptions } from '../../../test-utils'; 6 | 7 | describe('Create schedule', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | ); 17 | 18 | it('Creates a schedule w/o login', () => 19 | request(app) 20 | .post('/schedules') 21 | .send({ 22 | push: { 23 | endpoint: PushSubscriptions.ENDPOINT, 24 | }, 25 | schedule: { 26 | cronExpression: '* * * * * *', 27 | title: 'Title', 28 | message: 'message', 29 | icon: '/icons/star.png', 30 | }, 31 | }) 32 | .expect(200) 33 | .then((response) => { 34 | expect(response.body.id).to.not.be.null; 35 | expect(response.body.title).to.equal('Title'); 36 | expect(response.body.message).to.equal('message'); 37 | expect(response.body.icon).to.equal('/icons/star.png'); 38 | expect(response.body.cronExpression).to.equal('* * * * * *'); 39 | expect(response.body.enabled).to.be.true; 40 | })); 41 | 42 | it('Creates a schedule w/ login', () => 43 | request(app) 44 | .post('/schedules') 45 | .set('Authorization', `Bearer ${token}`) 46 | .send({ 47 | push: { 48 | endpoint: PushSubscriptions.ENDPOINT, 49 | }, 50 | schedule: { 51 | cronExpression: '* * * * * *', 52 | title: 'Title', 53 | message: 'message', 54 | icon: '/icons/star.png', 55 | }, 56 | }) 57 | .expect(200) 58 | .then((response) => { 59 | expect(response.body.id).to.not.be.null; 60 | expect(response.body.title).to.equal('Title'); 61 | expect(response.body.message).to.equal('message'); 62 | expect(response.body.icon).to.equal('/icons/star.png'); 63 | expect(response.body.cronExpression).to.equal('* * * * * *'); 64 | expect(response.body.enabled).to.be.true; 65 | expect(response.body.user).to.not.be.null; 66 | })); 67 | }); 68 | -------------------------------------------------------------------------------- /src/api/schedules/tests/delete-schedule.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../../../app'; 3 | import { init } from '../../../models'; 4 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 5 | 6 | describe('Delete schedule', () => { 7 | let token: string; 8 | let scheduleIds: string[]; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | .then(() => 17 | Schedules.init(token).then((ids) => { 18 | scheduleIds = ids; 19 | }) 20 | ) 21 | ); 22 | 23 | it('Deletes a schedule without a user', () => 24 | request(app).delete(`/schedules/${scheduleIds[0]}`).expect(200)); 25 | 26 | it('Does not delete a schedule with a user if token not provider', () => 27 | request(app).delete(`/schedules/${scheduleIds[1]}`).expect(401)); 28 | 29 | it('Deletes a schedule with a user', () => 30 | request(app) 31 | .delete(`/schedules/${scheduleIds[1]}`) 32 | .set('Authorization', `Bearer ${token}`) 33 | .expect(200)); 34 | }); 35 | -------------------------------------------------------------------------------- /src/api/schedules/tests/get-schedules.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 6 | 7 | describe('Get schedules', () => { 8 | let token: string; 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t: string) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | .then(() => Schedules.init(token)) 17 | ); 18 | 19 | it('Gets schedule by endpoint', () => 20 | request(app) 21 | .get('/schedules') 22 | .query({ endpoint: PushSubscriptions.ENDPOINT }) 23 | .expect(200) 24 | .then((response) => { 25 | expect(response.body.schedules).to.have.length(1); 26 | expect(response.body.schedules[0].enabled).to.be.true; 27 | })); 28 | 29 | it('Gets schedules by endpoint and user', () => 30 | request(app) 31 | .get('/schedules') 32 | .set('Authorization', `Bearer ${token}`) 33 | .query({ endpoint: PushSubscriptions.ENDPOINT }) 34 | .expect(200) 35 | .then((response) => { 36 | expect(response.body.schedules).to.have.length(2); 37 | })); 38 | }); 39 | -------------------------------------------------------------------------------- /src/api/schedules/tests/patch-schedule.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import request from 'supertest'; 3 | import app from '../../../app'; 4 | import { init } from '../../../models'; 5 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 6 | 7 | describe('Patch a schedule', () => { 8 | let token: string; 9 | let scheduleIds: string[]; 10 | before(() => 11 | init() 12 | .then(Auth.init) 13 | .then((t) => { 14 | token = t; 15 | }) 16 | .then(() => PushSubscriptions.init(token)) 17 | .then(() => 18 | Schedules.init(token).then((ids) => { 19 | scheduleIds = ids; 20 | }) 21 | ) 22 | ); 23 | 24 | it('Updates a schedule without a user', () => 25 | request(app) 26 | .patch(`/schedules/${scheduleIds[0]}`) 27 | .send({ 28 | schedule: { 29 | message: 'new message', 30 | cronExpression: '*/10 * * * * *', 31 | }, 32 | push: { 33 | endpoint: PushSubscriptions.ENDPOINT, 34 | }, 35 | enabled: true, 36 | }) 37 | .expect(200) 38 | .then((response) => { 39 | expect(response.body.message).to.equal('new message'); 40 | expect(response.body.cronExpression).to.equal('*/10 * * * * *'); 41 | expect(response.body.enabled).to.be.true; 42 | })); 43 | 44 | it('Cannot update schedule with user without token', () => 45 | request(app) 46 | .patch(`/schedules/${scheduleIds[1]}`) 47 | .send({ 48 | schedule: { 49 | message: 'new message', 50 | cronExpression: '*/10 * * * * *', 51 | }, 52 | push: { 53 | endpoint: PushSubscriptions.USER_ENDPOINT, 54 | }, 55 | enabled: true, 56 | }) 57 | .expect(403)); 58 | 59 | it('Adds a push subscription to schedule', () => 60 | request(app) 61 | .patch(`/schedules/${scheduleIds[0]}`) 62 | .send({ 63 | push: { 64 | endpoint: PushSubscriptions.USER_ENDPOINT, 65 | }, 66 | enabled: true, 67 | }) 68 | .then(() => 69 | request(app) 70 | .get('/schedules') 71 | .query({ endpoint: PushSubscriptions.USER_ENDPOINT }) 72 | .expect(200) 73 | .then((response) => { 74 | expect(response.body.schedules).to.have.length(2); 75 | }) 76 | )); 77 | 78 | it('Updates a schedule with user with token', () => 79 | request(app) 80 | .patch(`/schedules/${scheduleIds[0]}`) 81 | .set('Authorization', `Bearer ${token}`) 82 | .send({ 83 | schedule: { 84 | message: 'new message', 85 | cronExpression: '*/10 * * * * *', 86 | }, 87 | enabled: true, 88 | push: { 89 | endpoint: PushSubscriptions.USER_ENDPOINT, 90 | }, 91 | }) 92 | .expect(200) 93 | .then((response) => { 94 | expect(response.body.message).to.equal('new message'); 95 | expect(response.body.cronExpression).to.equal('*/10 * * * * *'); 96 | expect(response.body.enabled).to.be.true; 97 | })); 98 | 99 | it('Disables a schedule without a user', () => 100 | request(app) 101 | .patch(`/schedules/${scheduleIds[0]}`) 102 | .send({ 103 | push: { 104 | endpoint: PushSubscriptions.ENDPOINT, 105 | }, 106 | enabled: false, 107 | }) 108 | .expect(200) 109 | .then((response) => { 110 | expect(response.body.enabled).to.be.false; 111 | })); 112 | 113 | it('Disables a schedule with a user without a token', () => 114 | request(app) 115 | .patch(`/schedules/${scheduleIds[1]}`) 116 | .send({ 117 | push: { 118 | endpoint: PushSubscriptions.USER_ENDPOINT, 119 | }, 120 | enabled: false, 121 | }) 122 | .expect(200) 123 | .then((response) => { 124 | expect(response.body.enabled).to.be.false; 125 | })); 126 | }); 127 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import express from 'express'; 3 | import { auth, pushRouter, schedulesRouter } from './api'; 4 | 5 | dotenv.config(); 6 | 7 | const app = express(); 8 | app.use(express.json()); 9 | app.use(express.urlencoded({ extended: true })); 10 | 11 | app.use('/auth', auth.router); 12 | app.use('/push', auth.verify(false), pushRouter); 13 | app.use('/schedules', auth.verify(false), schedulesRouter); 14 | 15 | export default app; 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import { init } from './models'; 3 | import startNotifications from './notifications'; 4 | 5 | init().then(() => { 6 | startNotifications(); 7 | const { PORT } = process.env; 8 | app.listen(PORT, () => console.log(`App listening on port ${PORT}`)); 9 | }); 10 | -------------------------------------------------------------------------------- /src/models/db.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Author: Tyler Grinn 3 | * https://github.com/tylergrinn 4 | * 5 | * Asynchronous mysql singleton 6 | * Usage: 7 | * import * as db from './db'; 8 | * 9 | * // For a single query 10 | * 11 | * const results = await db.query('SELECT * FROM users'); 12 | * 13 | * // For sequential queries using a single connection 14 | * 15 | * const id = await db.useConnection(async (query) => { 16 | * await query('INSERT INTO cities (name, state) VALUES (`Ann Arbor`, `MI`); 17 | * 18 | * const [{ id }] = await query('SELECT LAST_INSERT_ID() AS id'); 19 | * 20 | * return id; 21 | * // The connection will be released once this inner async function completes 22 | * }) 23 | * 24 | * // Specify named parameters in the query string using the ':' symbol. 25 | * // Pass their values in to an object as the second parameter 26 | * 27 | * const [userExists] = await db.query( 28 | * 'SELECT COUNT(*) AS userExists FROM users WHERE username = :username', 29 | * { username: 'tyler' } 30 | * ) 31 | * 32 | * // All named parameters are escaped using mysql's EscapeFunctions.escape function 33 | * 34 | * Required environment variables: 35 | * MYSQL_HOST 36 | * MYSQL_PORT 37 | * MYSQL_USER 38 | * MYSQL_PASSWORD 39 | * MYSQL_DB 40 | * MYSQL_TEST_DB // used when NODE_ENV === 'test' 41 | */ 42 | import * as mysql from 'mysql'; 43 | 44 | export const sql = (strings: TemplateStringsArray, ...args: any[]): string => { 45 | let result = ''; 46 | 47 | strings.forEach((_, i) => { 48 | result += `${strings[i]}\`${mysql.escape(args[i])}\``; 49 | }); 50 | 51 | return result; 52 | }; 53 | 54 | export type Query = ( 55 | sql: string, 56 | args?: { [key: string]: string | number } 57 | ) => Promise>; 58 | 59 | const getQueryFnc: (conn: mysql.Pool | mysql.Connection) => Query = (conn) => ( 60 | sql, 61 | args 62 | ) => 63 | new Promise((resolve, reject) => { 64 | conn.query(sql, args, (err, rows) => { 65 | if (err) { 66 | reject(err); 67 | } else { 68 | resolve(rows); 69 | } 70 | }); 71 | }); 72 | 73 | const pool = mysql.createPool({ 74 | database: 75 | process.env.NODE_ENV === 'test' 76 | ? process.env.MYSQL_TEST_DB 77 | : process.env.MYSQL_DB, 78 | host: process.env.MYSQL_HOST, 79 | password: process.env.MYSQL_PASSWORD, 80 | port: Number(process.env.MYSQL_PORT), 81 | user: process.env.MYSQL_USER, 82 | connectionLimit: 10, 83 | }); 84 | 85 | export const query: Query = getQueryFnc(pool); 86 | 87 | export const useConnection: ( 88 | cb: (query: Query) => Promise 89 | ) => Promise = (cb) => 90 | new Promise((resolve, reject) => { 91 | pool.getConnection(async (err, conn) => { 92 | if (err) { 93 | reject(err); 94 | } else { 95 | let result; 96 | 97 | try { 98 | result = await cb(getQueryFnc(conn)); 99 | } catch (err) { 100 | reject(err); 101 | } finally { 102 | conn.release(); 103 | resolve(result); 104 | } 105 | } 106 | }); 107 | }); 108 | 109 | pool.on('connection', (conn) => { 110 | conn.config.queryFormat = (sql: string, values) => { 111 | if (!values) { 112 | return sql; 113 | } 114 | return sql.replace(/\:(\w+)/g, (txt, key) => { 115 | if (values.hasOwnProperty(key)) { 116 | return conn.escape(values[key]); 117 | } 118 | return txt; 119 | }); 120 | }; 121 | }); 122 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import * as notifications from './notifications'; 2 | import * as pushSubscriptions from './push-subscriptions'; 3 | import * as scheduleSubscriptions from './schedule-subscriptions'; 4 | import * as schedules from './schedules'; 5 | import * as users from './users'; 6 | 7 | export const init = () => 8 | Promise.all([ 9 | users.init(), 10 | pushSubscriptions.init(), 11 | schedules.init(), 12 | scheduleSubscriptions.init(), 13 | notifications.init(), 14 | ]); 15 | 16 | export { 17 | notifications, 18 | pushSubscriptions, 19 | schedules, 20 | scheduleSubscriptions, 21 | users, 22 | }; 23 | -------------------------------------------------------------------------------- /src/models/notifications/create-necessary.ts: -------------------------------------------------------------------------------- 1 | import cronParser from 'cron-parser'; 2 | import { pushSubscriptions } from '..'; 3 | import dateToMySQL from '../../util'; 4 | import * as db from '../db'; 5 | import createId from '../util/create-id'; 6 | import create from './create'; 7 | import Notification from './notification'; 8 | 9 | export default (now = new Date()) => 10 | db.useConnection(async (query) => { 11 | const exactMinute = new Date(now.valueOf()); 12 | exactMinute.setSeconds(0, 0); 13 | const justBefore = new Date(now.valueOf()); 14 | justBefore.setSeconds(-1, 0); 15 | 16 | const schedulesWithoutPendingNotifications = (await query( 17 | ` 18 | SELECT 19 | s.cronExpression, 20 | ps.timeZone, 21 | ss.id AS scheduleSubscriptionId, 22 | ps.endpoint 23 | FROM schedule_subscriptions ss 24 | INNER JOIN schedules s 25 | ON s.id = ss.scheduleId 26 | INNER JOIN push_subscriptions ps 27 | ON ( 28 | ps.id = ss.pushSubscriptionId 29 | AND ps.enabled 30 | ) 31 | LEFT JOIN notifications n 32 | ON ( 33 | n.scheduleSubscriptionId = ss.id 34 | AND n.date >= :date 35 | ) 36 | WHERE 37 | n.id IS NULL 38 | AND ss.enabled 39 | `, 40 | { date: dateToMySQL(exactMinute) } 41 | )) as { 42 | cronExpression: string; 43 | timeZone: string; 44 | scheduleSubscriptionId: string; 45 | endpoint: string; 46 | }[]; 47 | 48 | const notificationsToCreate: Notification[] = schedulesWithoutPendingNotifications.reduce( 49 | (notifications, s) => { 50 | try { 51 | const date = cronParser 52 | .parseExpression(s.cronExpression, { 53 | tz: s.timeZone, 54 | currentDate: justBefore, 55 | }) 56 | .next() 57 | .toDate(); 58 | 59 | notifications.push({ 60 | id: createId(), 61 | date: dateToMySQL(date), 62 | scheduleSubscriptionId: s.scheduleSubscriptionId, 63 | sent: false, 64 | }); 65 | } catch (err) { 66 | console.error(err); 67 | console.log(s); 68 | if (s.endpoint) pushSubscriptions.destroy(s.endpoint); 69 | } 70 | return notifications; 71 | }, 72 | [] as Notification[] 73 | ); 74 | 75 | if (notificationsToCreate.length > 0) { 76 | await create(notificationsToCreate); 77 | } 78 | }); 79 | -------------------------------------------------------------------------------- /src/models/notifications/create.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import Notification from './notification'; 3 | 4 | export default async (notifications: Notification[]) => { 5 | if (notifications.length > 0) { 6 | // prettier-ignore 7 | return db.query(` 8 | INSERT INTO notifications 9 | (id, date, scheduleSubscriptionId, sent) 10 | VALUES 11 | ${notifications.map((n) => 12 | `('${n.id}', '${n.date}', '${n.scheduleSubscriptionId}', ${n.sent}),` 13 | ).join('\n').slice(0, -1)} 14 | `); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/models/notifications/destroy.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | export default (scheduleId: string) => 4 | db.query( 5 | ` 6 | DELETE n 7 | FROM notifications n 8 | INNER JOIN schedule_subscriptions ss 9 | ON ( 10 | ss.id = n.scheduleSubscriptionId 11 | AND ss.scheduleId = :scheduleId 12 | ) 13 | `, 14 | { scheduleId } 15 | ); 16 | -------------------------------------------------------------------------------- /src/models/notifications/find.ts: -------------------------------------------------------------------------------- 1 | import dateToMySQL from '../../util'; 2 | import * as db from '../db'; 3 | import PushSubscription from '../push-subscriptions/push-subscription'; 4 | import Schedule from '../schedules/schedule'; 5 | import Notification from './notification'; 6 | 7 | export default async ( 8 | now = new Date() 9 | ): Promise< 10 | Array 11 | > => { 12 | const exactMinute = new Date(now.valueOf()); 13 | exactMinute.setSeconds(0, 0); 14 | 15 | const result = await db.query( 16 | ` 17 | SELECT 18 | n.id, n.date, n.sent, n.scheduleSubscriptionId, 19 | s.id AS scheduleId, s.cronExpression, s.title, s.icon, s.message, s.userId, 20 | ps.endpoint, ps.timeZone, ps.p256dh, ps.auth 21 | FROM notifications n 22 | INNER JOIN schedule_subscriptions ss 23 | ON ( 24 | ss.id = n.scheduleSubscriptionId 25 | AND ss.enabled 26 | ) 27 | LEFT JOIN schedules s ON s.id = ss.scheduleId 28 | LEFT JOIN push_subscriptions ps 29 | ON ( 30 | ps.id = ss.pushSubscriptionId 31 | AND ps.enabled 32 | ) 33 | WHERE 34 | date = :date 35 | AND sent = 0 36 | `, 37 | { date: dateToMySQL(exactMinute) } 38 | ); 39 | 40 | return result.map((n) => ({ 41 | id: n.id as string, 42 | enabled: true, 43 | date: n.date as string, 44 | sent: !!n.sent, 45 | scheduleSubscriptionId: n.scheduleSubscriptionId as string, 46 | cronExpression: n.cronExpression as string, 47 | title: n.title as string, 48 | icon: n.icon as string, 49 | message: n.message as string, 50 | userId: n.userId as string, 51 | endpoint: n.endpoint as string, 52 | timeZone: n.timeZone as string, 53 | keys: { 54 | p256dh: n.p256dh as string, 55 | auth: n.auth as string, 56 | }, 57 | scheduleId: n.scheduleId as string, 58 | })); 59 | }; 60 | -------------------------------------------------------------------------------- /src/models/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import create from './create'; 2 | import createNecessary from './create-necessary'; 3 | import destroy from './destroy'; 4 | import find from './find'; 5 | import init from './init'; 6 | import Notification from './notification'; 7 | import update from './update'; 8 | 9 | export { init, Notification, create, createNecessary, destroy, find, update }; 10 | -------------------------------------------------------------------------------- /src/models/notifications/init.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | const TABLE_NAME = 'notifications'; 4 | 5 | export default () => 6 | db.useConnection(async (query) => { 7 | if (process.env.NODE_ENV === 'test') { 8 | await query(`DROP TABLE IF EXISTS ${TABLE_NAME}`); 9 | } 10 | 11 | return query(` 12 | CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 13 | id VARCHAR(255) NOT NULL, 14 | sent TINYINT(1) DEFAULT 0, 15 | date DATETIME NOT NULL, 16 | scheduleSubscriptionId VARCHAR(255) NOT NULL, 17 | createdAt TIMESTAMP NOT NULL, 18 | updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(), 19 | PRIMARY KEY (id) 20 | ) 21 | `); 22 | }); 23 | -------------------------------------------------------------------------------- /src/models/notifications/notification.ts: -------------------------------------------------------------------------------- 1 | export default interface Notification { 2 | id: string; 3 | sent: boolean; 4 | date: string; 5 | scheduleSubscriptionId: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/models/notifications/test/create-necessary-notifications.spec.ts: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import proxyquire from 'proxyquire'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | import { init } from '../..'; 6 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 7 | import * as create from '../create'; 8 | 9 | chai.use(sinonChai); 10 | 11 | describe('Create necessary notifications', () => { 12 | let token: string; 13 | let createSpy: sinon.SinonSpy; 14 | let createNecessaryNotifications: typeof import('../create-necessary').default; 15 | const now = new Date(); 16 | 17 | before(() => 18 | init() 19 | .then(Auth.init) 20 | .then((t: string) => { 21 | token = t; 22 | }) 23 | .then(() => PushSubscriptions.init(token)) 24 | .then(() => Schedules.init(token)) 25 | ); 26 | 27 | beforeEach(() => { 28 | createSpy = sinon.spy(create, 'default'); 29 | createNecessaryNotifications = proxyquire('../create-necessary', { 30 | create, 31 | }).default; 32 | }); 33 | 34 | afterEach(sinon.restore); 35 | 36 | it('Creates notifications for all schedules', async () => { 37 | await createNecessaryNotifications(now); 38 | expect(createSpy).to.have.been.calledOnce; 39 | expect(createSpy.getCall(0).args[0]).has.length(2); 40 | }); 41 | 42 | it('It does not recreate notifications if they already exist in the future', async () => { 43 | await createNecessaryNotifications(now); 44 | expect(createSpy).to.not.have.been.called; 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/models/notifications/test/find-notifications.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as notifications from '..'; 3 | import { init } from '../..'; 4 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 5 | 6 | describe('Finds notifications to be sent', () => { 7 | let token: string; 8 | 9 | before(() => 10 | init() 11 | .then(Auth.init) 12 | .then((t: string) => { 13 | token = t; 14 | }) 15 | .then(() => PushSubscriptions.init(token)) 16 | .then(() => Schedules.init(token)) 17 | ); 18 | 19 | it('Finds both notifications if minutes are even', async () => { 20 | const now = new Date(); 21 | now.setMinutes(28); 22 | await notifications.createNecessary(now); 23 | const result = await notifications.find(now); 24 | expect(result).to.have.length(2); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/models/notifications/update.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import Notification from './notification'; 3 | 4 | export default (notification: Notification) => 5 | db.query( 6 | ` 7 | UPDATE notifications 8 | SET sent = :sent 9 | WHERE id = :id 10 | `, 11 | { 12 | id: notification.id, 13 | sent: Number(notification.sent), 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/create.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import createId from '../util/create-id'; 3 | import PushSubscription from './push-subscription'; 4 | 5 | export default async (pushSubscription: PushSubscription) => { 6 | const id = createId(); 7 | 8 | await db.query( 9 | ` 10 | INSERT INTO push_subscriptions ( 11 | id, userId, enabled, endpoint, p256dh, auth, timeZone 12 | ) VALUES ( 13 | :id, :userId, :enabled, :endpoint, :p256dh, :auth, :timeZone 14 | ) 15 | `, 16 | { 17 | id, 18 | userId: pushSubscription.userId || '', 19 | enabled: Number(pushSubscription.enabled), 20 | endpoint: pushSubscription.endpoint, 21 | p256dh: pushSubscription.keys.p256dh, 22 | auth: pushSubscription.keys.auth, 23 | timeZone: pushSubscription.timeZone, 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/destroy.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | export default (endpoint: string) => 4 | db.query( 5 | ` 6 | DELETE ps, ss 7 | FROM schedule_subscriptions ss 8 | INNER JOIN push_subscriptions ps 9 | ON ( 10 | ps.endpoint = :endpoint 11 | AND ss.pushSubscriptionId = ps.id 12 | ) 13 | `, 14 | { endpoint } 15 | ); 16 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/exists.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | export default async (endpoint: string): Promise => { 4 | const [{ pushSubscriptionExists }] = await db.query( 5 | ` 6 | SELECT COUNT(*) AS pushSubscriptionExists 7 | FROM push_subscriptions 8 | WHERE endpoint = :endpoint 9 | `, 10 | { endpoint } 11 | ); 12 | 13 | return !!pushSubscriptionExists; 14 | }; 15 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/find.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import PushSubscription from './push-subscription'; 3 | 4 | export default async ( 5 | endpoint: string 6 | ): Promise => { 7 | const [rawPushSubscription] = await db.query( 8 | ` 9 | SELECT userId, enabled, endpoint, p256dh, auth, timeZone 10 | FROM push_subscriptions 11 | WHERE endpoint = :endpoint 12 | `, 13 | { endpoint } 14 | ); 15 | 16 | if (rawPushSubscription) { 17 | return { 18 | userId: rawPushSubscription.userId as string, 19 | enabled: !!rawPushSubscription.enabled, 20 | endpoint: rawPushSubscription.endpoint as string, 21 | keys: { 22 | p256dh: rawPushSubscription.p256dh as string, 23 | auth: rawPushSubscription.auth as string, 24 | }, 25 | timeZone: rawPushSubscription.timeZone as string, 26 | }; 27 | } 28 | 29 | return undefined; 30 | }; 31 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/index.ts: -------------------------------------------------------------------------------- 1 | import create from './create'; 2 | import destroy from './destroy'; 3 | import exists from './exists'; 4 | import find from './find'; 5 | import init from './init'; 6 | import PushSubscription from './push-subscription'; 7 | import sanitize from './sanitize'; 8 | import update from './update'; 9 | 10 | export { 11 | update, 12 | find, 13 | init, 14 | exists, 15 | PushSubscription, 16 | create, 17 | sanitize, 18 | destroy, 19 | }; 20 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/init.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | const TABLE_NAME = 'push_subscriptions'; 4 | 5 | export default () => 6 | db.useConnection(async (query) => { 7 | if (process.env.NODE_ENV === 'test') { 8 | await query(`DROP TABLE IF EXISTS ${TABLE_NAME}`); 9 | } 10 | 11 | await query(` 12 | CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 13 | id VARCHAR(255) NOT NULL, 14 | userId VARCHAR(255) NOT NULL, 15 | enabled TINYINT(1) DEFAULT 1, 16 | endpoint TEXT NOT NULL, 17 | p256dh VARCHAR(255) NOT NULL, 18 | auth VARCHAR(255) NOT NULL, 19 | timeZone VARCHAR(255) NOT NULL, 20 | createdAt TIMESTAMP NOT NULL, 21 | updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(), 22 | PRIMARY KEY (id) 23 | ) 24 | `); 25 | }); 26 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/push-subscription.ts: -------------------------------------------------------------------------------- 1 | export default interface PushSubscription { 2 | enabled: boolean; 3 | userId?: string; 4 | endpoint: string; 5 | timeZone: string; 6 | keys: { 7 | p256dh: string; 8 | auth: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/sanitize.ts: -------------------------------------------------------------------------------- 1 | import PushSubscription from './push-subscription'; 2 | 3 | type SanitizedPushSubscription = Omit; 4 | 5 | export default ( 6 | pushSubscription: PushSubscription 7 | ): SanitizedPushSubscription => ({ 8 | endpoint: pushSubscription.endpoint, 9 | enabled: pushSubscription.enabled, 10 | userId: pushSubscription.userId, 11 | timeZone: pushSubscription.timeZone, 12 | }); 13 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/tests/destroy-push-subscription.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { init } from '../..'; 3 | import { Auth, PushSubscriptions, Schedules } from '../../../test-utils'; 4 | import * as db from '../../db'; 5 | import destroyPushSubscription from '../destroy'; 6 | 7 | describe('Destroy a push subscription', () => { 8 | let subscriptionIds: string[] = []; 9 | let token: string; 10 | before(() => 11 | init() 12 | .then(Auth.init) 13 | .then((t) => { 14 | token = t; 15 | }) 16 | .then(() => PushSubscriptions.init(token)) 17 | .then(() => 18 | db.useConnection(async (query) => { 19 | subscriptionIds.push( 20 | ( 21 | await query( 22 | `SELECT id FROM push_subscriptions WHERE endpoint = "${PushSubscriptions.ENDPOINT}"` 23 | ) 24 | )[0].id as string 25 | ); 26 | subscriptionIds.push( 27 | ( 28 | await query( 29 | `SELECT id FROM push_subscriptions WHERE endpoint = "${PushSubscriptions.USER_ENDPOINT}"` 30 | ) 31 | )[0].id as string 32 | ); 33 | }) 34 | ) 35 | .then(() => Schedules.init(token)) 36 | ); 37 | 38 | it('Destroys all schedule_subscriptions and a push subscription from an endpoint', async () => { 39 | await destroyPushSubscription(PushSubscriptions.ENDPOINT); 40 | let sss = await db.query( 41 | `SELECT * FROM schedule_subscriptions WHERE pushSubscriptionId = "${subscriptionIds[0]}"` 42 | ); 43 | expect(sss).to.have.length(0); 44 | sss = await db.query( 45 | `SELECT * FROM schedule_subscriptions WHERE pushSubscriptionId = "${subscriptionIds[1]}"` 46 | ); 47 | expect(sss).to.have.length(1); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/models/push-subscriptions/update.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import PushSubscription from './push-subscription'; 3 | 4 | export default async (pushSubscription: PushSubscription) => { 5 | await db.query( 6 | ` 7 | UPDATE push_subscriptions 8 | SET 9 | ${pushSubscription.userId ? 'userId = :userId,' : ''} 10 | enabled = :enabled, 11 | timeZone = :timeZone, 12 | p256dh = :p256dh, 13 | auth = :auth 14 | WHERE 15 | endpoint = :endpoint 16 | `, 17 | { 18 | endpoint: pushSubscription.endpoint, 19 | userId: pushSubscription.userId as string, 20 | enabled: Number(pushSubscription.enabled), 21 | timeZone: pushSubscription.timeZone, 22 | p256dh: pushSubscription.keys.p256dh, 23 | auth: pushSubscription.keys.auth, 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/create.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import createId from '../util/create-id'; 3 | import ScheduleSubscription from './schedule-subscription'; 4 | 5 | export default ( 6 | endpoint: string, 7 | scheduleId: string, 8 | enabled: boolean 9 | ): Promise => 10 | db.useConnection(async (query) => { 11 | const [ 12 | { pushSubscriptionId }, 13 | ] = (await db.query( 14 | `SELECT id AS pushSubscriptionId FROM push_subscriptions WHERE endpoint = :endpoint`, 15 | { endpoint } 16 | )) as [{ pushSubscriptionId: string }]; 17 | 18 | if (!pushSubscriptionId) { 19 | throw new Error('Push subscription does not exist'); 20 | } 21 | 22 | const scheduleSubscription: ScheduleSubscription = { 23 | id: createId(), 24 | endpoint, 25 | scheduleId, 26 | enabled, 27 | pushSubscriptionId, 28 | }; 29 | 30 | db.query( 31 | ` 32 | INSERT INTO schedule_subscriptions ( 33 | pushSubscriptionId, 34 | id, scheduleId, enabled 35 | ) VALUES ( 36 | (SELECT id FROM push_subscriptions WHERE endpoint = :endpoint), 37 | :id, :scheduleId, :enabled 38 | ) 39 | `, 40 | { 41 | ...scheduleSubscription, 42 | enabled: Number(scheduleSubscription.enabled), 43 | } 44 | ); 45 | 46 | return scheduleSubscription; 47 | }); 48 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/find-schedules.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import { Schedule } from '../schedules'; 3 | 4 | export default async ({ 5 | endpoint, 6 | userId, 7 | }: { 8 | endpoint: string; 9 | userId?: string; 10 | }): Promise => 11 | db 12 | .query( 13 | ` 14 | SELECT 15 | s.id, s.cronExpression, s.title, s.icon, s.message, s.userId, enabled.enabled 16 | FROM schedules s 17 | LEFT JOIN ( 18 | SELECT 19 | ss.scheduleId, IFNULL(ps.enabled AND ss.enabled, FALSE) AS enabled 20 | FROM schedule_subscriptions ss 21 | INNER JOIN push_subscriptions ps 22 | ON ( 23 | ps.id = ss.pushSubscriptionId 24 | AND ps.endpoint = :endpoint 25 | ) 26 | ORDER BY enabled DESC 27 | ) enabled 28 | ON enabled.scheduleId = s.id 29 | WHERE 30 | s.userId = :userId 31 | OR enabled.enabled IS NOT NULL 32 | `, 33 | { 34 | endpoint, 35 | userId: userId as string, 36 | } 37 | ) 38 | .then((result) => 39 | result.map((s) => ({ 40 | id: s.id as string, 41 | cronExpression: s.cronExpression as string, 42 | title: s.title as string, 43 | icon: s.icon as string, 44 | message: s.message as string, 45 | userId: s.userId as string, 46 | enabled: !!s.enabled, 47 | })) 48 | ); 49 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/find.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import ScheduleSubscription from './schedule-subscription'; 3 | 4 | export default async ( 5 | endpoint: string, 6 | scheduleId: string 7 | ): Promise => { 8 | const [scheduleSubscription] = await db.query( 9 | ` 10 | SELECT 11 | ss.id, ss.pushSubscriptionId, ss.scheduleId, ss.enabled 12 | FROM schedule_subscriptions ss 13 | INNER JOIN push_subscriptions ps 14 | ON ( 15 | ps.id = ss.pushSubscriptionId 16 | AND ps.endpoint = :endpoint 17 | ) 18 | WHERE scheduleId = :scheduleId 19 | `, 20 | { 21 | endpoint, 22 | scheduleId, 23 | } 24 | ); 25 | 26 | if (scheduleSubscription) { 27 | return { 28 | id: scheduleSubscription.id as string, 29 | endpoint, 30 | pushSubscriptionId: scheduleSubscription.pushSubscriptionId as string, 31 | scheduleId: scheduleSubscription.scheduleId as string, 32 | enabled: !!scheduleSubscription.enabled, 33 | }; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/index.ts: -------------------------------------------------------------------------------- 1 | import create from './create'; 2 | import find from './find'; 3 | import findSchedules from './find-schedules'; 4 | import init from './init'; 5 | import ScheduleSubscription from './schedule-subscription'; 6 | import update from './update'; 7 | 8 | export { create, findSchedules, init, find, ScheduleSubscription, update }; 9 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/init.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | const TABLE_NAME = 'schedule_subscriptions'; 4 | 5 | export default () => 6 | db.useConnection(async (query) => { 7 | if (process.env.NODE_ENV === 'test') { 8 | await query(`DROP TABLE IF EXISTS ${TABLE_NAME}`); 9 | } 10 | 11 | await query(` 12 | CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 13 | id VARCHAR(255) NOT NULL, 14 | enabled TINYINT(1) DEFAULT 1, 15 | scheduleId VARCHAR(255) NOT NULL, 16 | pushSubscriptionId VARCHAR(255) NOT NULL, 17 | createdAt TIMESTAMP NOT NULL, 18 | updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(), 19 | PRIMARY KEY (id) 20 | ) 21 | `); 22 | }); 23 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/schedule-subscription.ts: -------------------------------------------------------------------------------- 1 | export default interface ScheduleSubscription { 2 | id: string; 3 | pushSubscriptionId: string; 4 | scheduleId: string; 5 | enabled: boolean; 6 | endpoint: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/schedule-subscriptions/update.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import ScheduleSubscription from './schedule-subscription'; 3 | 4 | export default async (scheduleSubscription: ScheduleSubscription) => { 5 | await db.query( 6 | ` 7 | UPDATE schedule_subscriptions 8 | SET enabled = :enabled 9 | WHERE id = :id 10 | `, 11 | { 12 | id: scheduleSubscription.id, 13 | enabled: Number(scheduleSubscription.enabled), 14 | } 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/models/schedules/create.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import createId from '../util/create-id'; 3 | import Schedule from './schedule'; 4 | 5 | interface CreateScheduleObject extends Partial { 6 | cronExpression: string; 7 | title: string; 8 | } 9 | 10 | export default async (schedule: CreateScheduleObject): Promise => { 11 | const newSchedule = { 12 | id: createId(), 13 | userId: schedule.userId || '', 14 | cronExpression: schedule.cronExpression, 15 | title: schedule.title, 16 | icon: schedule.icon || '/icons/star.png', 17 | message: schedule.message || '', 18 | }; 19 | 20 | await db.query( 21 | ` 22 | INSERT INTO schedules ( 23 | id, userId, cronExpression, title, icon, message 24 | ) VALUES ( 25 | :id, :userId, :cronExpression, :title, :icon, :message 26 | ) 27 | `, 28 | newSchedule 29 | ); 30 | 31 | return newSchedule; 32 | }; 33 | -------------------------------------------------------------------------------- /src/models/schedules/destroy.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | export default async (id: string) => 4 | db.query( 5 | ` 6 | DELETE FROM schedules 7 | WHERE id = :id 8 | `, 9 | { id } 10 | ); 11 | -------------------------------------------------------------------------------- /src/models/schedules/find.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import Schedule from './schedule'; 3 | 4 | export default async (id: string): Promise => { 5 | const [result] = await db.query( 6 | ` 7 | SELECT id, cronExpression, title, icon, message, userId 8 | FROM schedules 9 | WHERE id = :id 10 | `, 11 | { id } 12 | ); 13 | 14 | if (result) { 15 | return { 16 | id: result.id as string, 17 | cronExpression: result.cronExpression as string, 18 | title: result.title as string, 19 | icon: result.icon as string, 20 | message: result.message as string, 21 | userId: result.userId as string, 22 | }; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/models/schedules/index.ts: -------------------------------------------------------------------------------- 1 | import create from './create'; 2 | import destroy from './destroy'; 3 | import find from './find'; 4 | import init from './init'; 5 | import Schedule from './schedule'; 6 | import update from './update'; 7 | 8 | export { create, Schedule, init, find, destroy, update }; 9 | -------------------------------------------------------------------------------- /src/models/schedules/init.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | const TABLE_NAME = 'schedules'; 4 | 5 | export default () => 6 | db.useConnection(async (query) => { 7 | if (process.env.NODE_ENV === 'test') { 8 | await query(`DROP TABLE IF EXISTS ${TABLE_NAME}`); 9 | } 10 | 11 | await query(` 12 | CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 13 | id VARCHAR(255) NOT NULL, 14 | userId VARCHAR(255), 15 | cronExpression VARCHAR(255) NOT NULL, 16 | title VARCHAR(255) NOT NULL, 17 | message VARCHAR(255), 18 | icon VARCHAR(255) NOT NULL, 19 | createdAt TIMESTAMP NOT NULL, 20 | updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(), 21 | PRIMARY KEY (id) 22 | ) 23 | `); 24 | }); 25 | -------------------------------------------------------------------------------- /src/models/schedules/schedule.ts: -------------------------------------------------------------------------------- 1 | export default interface Schedule { 2 | id: string; 3 | cronExpression: string; 4 | title: string; 5 | icon: string; 6 | message?: string; 7 | userId?: string; 8 | enabled?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/schedules/update.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import Schedule from './schedule'; 3 | 4 | export default (schedule: Schedule) => 5 | db.query( 6 | ` 7 | UPDATE schedules 8 | SET 9 | ${schedule.userId ? 'userId = :userId,' : ''} 10 | ${schedule.message ? 'message = :message,' : ''} 11 | ${schedule.icon ? 'icon = :icon,' : ''} 12 | cronExpression = :cronExpression, 13 | title = :title 14 | WHERE id = :id 15 | `, 16 | { 17 | id: schedule.id, 18 | userId: schedule.userId as string, 19 | cronExpression: schedule.cronExpression, 20 | title: schedule.title, 21 | message: schedule.message as string, 22 | icon: schedule.icon as string, 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /src/models/users/create.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import createId from '../util/create-id'; 3 | import find from './find'; 4 | import User from './user'; 5 | 6 | interface CreateUserObject extends Partial { 7 | username: string; 8 | password: string; 9 | } 10 | 11 | export default async (info: CreateUserObject): Promise => { 12 | const id = createId(); 13 | 14 | await db.query( 15 | ` 16 | INSERT INTO users ( 17 | id, username, password 18 | ) VALUES ( 19 | :id, :username, :password 20 | ) 21 | `, 22 | { 23 | id, 24 | username: info.username, 25 | password: info.password, 26 | } 27 | ); 28 | 29 | return find({ id }) as Promise; 30 | }; 31 | -------------------------------------------------------------------------------- /src/models/users/exists.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | export default async (username: string): Promise => { 4 | const [ 5 | { userExists }, 6 | ] = await db.query( 7 | 'SELECT COUNT(*) AS userExists FROM users WHERE username = :username', 8 | { username } 9 | ); 10 | 11 | return !!userExists; 12 | }; 13 | -------------------------------------------------------------------------------- /src/models/users/find.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | import XOR from '../util/x-or'; 3 | import User from './user'; 4 | 5 | export default async ( 6 | query: XOR<{ username: string }, { id: string }> 7 | ): Promise => { 8 | const [user] = await db.query( 9 | ` 10 | SELECT 11 | id, username, createdAt, updatedAt, password 12 | FROM users 13 | WHERE 14 | ${query.username ? 'username = :username' : 'id = :id'} 15 | `, 16 | { 17 | username: query.username as string, 18 | id: query.id as string, 19 | } 20 | ); 21 | 22 | if (user) { 23 | return { 24 | id: user.id as string, 25 | username: user.username as string, 26 | createdAt: user.createdAt as string, 27 | updatedAt: user.updatedAt as string, 28 | password: user.password as string, 29 | }; 30 | } 31 | 32 | return undefined; 33 | }; 34 | -------------------------------------------------------------------------------- /src/models/users/index.ts: -------------------------------------------------------------------------------- 1 | import create from './create'; 2 | import exists from './exists'; 3 | import find from './find'; 4 | import init from './init'; 5 | import sanitize from './sanitize'; 6 | import User from './user'; 7 | 8 | export { User, sanitize, init, exists, find, create }; 9 | -------------------------------------------------------------------------------- /src/models/users/init.ts: -------------------------------------------------------------------------------- 1 | import * as db from '../db'; 2 | 3 | const TABLE_NAME = 'users'; 4 | 5 | export default () => 6 | db.useConnection(async (query) => { 7 | if (process.env.NODE_ENV === 'test') { 8 | await query(`DROP TABLE IF EXISTS ${TABLE_NAME}`); 9 | } 10 | 11 | await query(` 12 | CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( 13 | id VARCHAR(255) NOT NULL, 14 | username VARCHAR(255) NOT NULL, 15 | password VARCHAR(255) NOT NULL, 16 | createdAt TIMESTAMP NOT NULL, 17 | updatedAt TIMESTAMP NOT NULL DEFAULT NOW() ON UPDATE NOW(), 18 | PRIMARY KEY (id) 19 | ) 20 | `); 21 | }); 22 | -------------------------------------------------------------------------------- /src/models/users/sanitize.ts: -------------------------------------------------------------------------------- 1 | import User from './user'; 2 | 3 | type SanitizedUser = Omit; 4 | 5 | export default (user: User): SanitizedUser => ({ 6 | id: user.id, 7 | username: user.username, 8 | createdAt: user.createdAt, 9 | updatedAt: user.updatedAt, 10 | token: user.token, 11 | }); 12 | -------------------------------------------------------------------------------- /src/models/users/user.ts: -------------------------------------------------------------------------------- 1 | export default interface User { 2 | id: string; 3 | username: string; 4 | createdAt: string; 5 | updatedAt: string; 6 | password: string; 7 | token?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/util/create-id.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | export default uuidv4; 3 | -------------------------------------------------------------------------------- /src/models/util/x-or.d.ts: -------------------------------------------------------------------------------- 1 | type Without = { [P in Exclude]?: never }; 2 | type XOR = T | U extends object 3 | ? (Without & U) | (Without & T) 4 | : T | U; 5 | 6 | export default XOR; 7 | -------------------------------------------------------------------------------- /src/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import { notifications } from '../models'; 2 | import onTheMinute from './on-the-minute'; 3 | import sendNotifications from './send-notifications'; 4 | 5 | export default () => 6 | onTheMinute(() => notifications.createNecessary().then(sendNotifications)); 7 | -------------------------------------------------------------------------------- /src/notifications/on-the-minute.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | export default (fn: () => any): (() => void) => { 3 | let timeout: NodeJS.Timeout; 4 | 5 | const helper = () => { 6 | const nextMinute = new Date(); 7 | nextMinute.setSeconds(60, 0); 8 | const delay = nextMinute.getTime() - new Date().getTime(); 9 | 10 | timeout = setTimeout(() => { 11 | fn(); 12 | helper(); 13 | }, delay); 14 | }; 15 | 16 | helper(); 17 | 18 | return () => { 19 | clearTimeout(timeout); 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/notifications/send-notifications.ts: -------------------------------------------------------------------------------- 1 | import webPush from 'web-push'; 2 | import { notifications, pushSubscriptions } from '../models'; 3 | 4 | webPush.setGCMAPIKey(process.env.GCM_API_KEY || null); 5 | webPush.setVapidDetails( 6 | 'mailto:tyler@tygr.info', 7 | process.env.VAPID_PUBLIC_KEY as string, 8 | process.env.VAPID_PRIVATE_KEY as string 9 | ); 10 | 11 | export default async (now: Date) => 12 | Promise.allSettled( 13 | (await notifications.find()).map((notification) => { 14 | const pushSubscription = { 15 | endpoint: notification.endpoint, 16 | keys: notification.keys, 17 | }; 18 | 19 | const payload = { 20 | title: notification.title, 21 | body: notification.message, 22 | icon: notification.icon, 23 | badge: notification.icon, 24 | tag: notification.scheduleId, 25 | renotify: true, 26 | requireInteraction: true, 27 | timestamp: notification.date, 28 | actions: [ 29 | { 30 | action: 'edit', 31 | title: 'Edit', 32 | }, 33 | ], 34 | }; 35 | 36 | return webPush 37 | .sendNotification(pushSubscription, JSON.stringify(payload)) 38 | .then(() => { 39 | notification.sent = true; 40 | return notifications.update(notification); 41 | }) 42 | .catch((err) => { 43 | console.error(err); 44 | console.log(notification); 45 | return pushSubscriptions.destroy(notification.endpoint); 46 | }); 47 | }) 48 | ); 49 | -------------------------------------------------------------------------------- /src/test-utils/auth.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../app'; 3 | 4 | export const USERNAME = 'test'; 5 | export const PASSWORD = 'test'; 6 | 7 | export const init = () => 8 | new Promise((resolve) => { 9 | request(app) 10 | .post('/auth/register') 11 | .send({ username: USERNAME, password: PASSWORD }) 12 | .then((response) => { 13 | resolve(response.body.token); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/test-utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as Auth from './auth'; 2 | import * as PushSubscriptions from './push-subscriptions'; 3 | import * as Schedules from './schedules'; 4 | 5 | export { Auth, PushSubscriptions, Schedules }; 6 | -------------------------------------------------------------------------------- /src/test-utils/push-subscriptions.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../app'; 3 | 4 | export const ENDPOINT = 'endpoint'; 5 | export const USER_ENDPOINT = 'user-endpoint'; 6 | 7 | export const init = async (token: string): Promise => { 8 | await request(app) 9 | .post('/push') 10 | .send({ 11 | endpoint: ENDPOINT, 12 | keys: { 13 | p256dh: 'test-p256dh', 14 | auth: 'test-auth', 15 | }, 16 | timeZone: 'America/New_York', 17 | }); 18 | 19 | await request(app) 20 | .post('/push') 21 | .set('Authorization', `Bearer ${token}`) 22 | .send({ 23 | endpoint: USER_ENDPOINT, 24 | keys: { 25 | p256dh: 'test-p256dh', 26 | auth: 'test-auth', 27 | }, 28 | timeZone: 'America/New_York', 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/test-utils/schedules.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../app'; 3 | import * as PushSubscriptions from './push-subscriptions'; 4 | 5 | export const init = async (token: string) => { 6 | const allScheduleId = await request(app) 7 | .post('/schedules') 8 | .send({ 9 | push: { 10 | endpoint: PushSubscriptions.ENDPOINT, 11 | }, 12 | schedule: { 13 | cronExpression: '* * * * *', 14 | title: 'Title', 15 | message: 'message', 16 | icon: '/icons/star.png', 17 | enabled: true, 18 | }, 19 | }) 20 | .then((response) => response.body.id); 21 | 22 | const evenScheduleId = await request(app) 23 | .post('/schedules') 24 | .set('Authorization', `Bearer ${token}`) 25 | .send({ 26 | push: { 27 | endpoint: PushSubscriptions.USER_ENDPOINT, 28 | }, 29 | schedule: { 30 | cronExpression: '*/2 * * * *', 31 | title: 'Title', 32 | message: 'message', 33 | icon: '/icons/star.png', 34 | enabled: true, 35 | }, 36 | }) 37 | .then((response) => response.body.id); 38 | 39 | return [allScheduleId, evenScheduleId]; 40 | }; 41 | 42 | export default init; 43 | -------------------------------------------------------------------------------- /src/typings/express.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | import { User as MyUser } from '../models/users'; 3 | 4 | declare global { 5 | namespace Express { 6 | interface User extends MyUser {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | const dateToMySQL = (date: Date): string => 2 | date.toISOString().slice(0, 19).replace('T', ' '); 3 | 4 | export default dateToMySQL; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "strict": true, 6 | "importHelpers": true, 7 | "sourceMap": true, 8 | "baseUrl": ".", 9 | "allowJs": true, 10 | "outDir": "bin", 11 | "noImplicitAny": true, 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true, 14 | "types": ["node", "mocha"], 15 | "lib": ["esnext"], 16 | "typeRoots": ["src/typings"] 17 | }, 18 | "include": ["src/**/*.ts", "src/notifications/tests/temp"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------