first commit

This commit is contained in:
Mathieu Lussier 2024-07-03 14:52:30 -04:00
commit f1e1806f2b
Signed by: mathieulussier
GPG Key ID: EF8AC4E6BA8BCAB3
67 changed files with 12837 additions and 0 deletions

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
logs
npm-debug.log
Dockerfile
docker*.yml
.git
.gitignore
.config
.npm
.vscode
node_modules
README.md
data
build
samples

13
.env.template Normal file
View File

@ -0,0 +1,13 @@
NODE_ENV=development
# Webhook
WEBHOOK_SHARED_KEY=mykey
# JWT
JWT_TOKEN_SECRET=JWT_TOKEN_SECRET
JWT_ACCESS_TOKEN_EXPIRES_IN=5m
JWT_REFRESH_TOKEN_EXPIRES_IN=30d
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

35
.eslintrc.js Normal file
View File

@ -0,0 +1,35 @@
module.exports = {
env: {
es2021: true,
node: true,
jest: true,
},
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'prettier'],
rules: {
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'class-methods-use-this': 'off',
'no-param-reassign': 'off',
camelcase: 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
},
};

145
.gitignore vendored Normal file
View File

@ -0,0 +1,145 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Environment variables
.env.development
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
configs/database.json
data
Dockerfile
docker-compose.yml
build/
.idea
tmp/
public/
!public/.gitkeep
configs/pandrive-google-service-account.json

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"semi": true
}

8
.sequelizerc Normal file
View File

@ -0,0 +1,8 @@
const path = require("path");
module.exports = {
config: path.resolve("configs", "database.json"),
"models-path": path.resolve("src", "models"),
"seeders-path": path.resolve("db", "seeds"),
"migrations-path": path.resolve("db", "migrations")
};

23
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "debug server",
"type": "node",
"request": "launch",
"args": [
"${workspaceFolder}/src/server.ts"
],
"runtimeArgs": [
"-r",
"ts-node/register"
],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
}
]
}

21
Dockerfile.development Normal file
View File

@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm install --silent
COPY src ./src
COPY public ./public
COPY db ./db
COPY configs ./configs
COPY bin ./bin
COPY tsconfig.json .
COPY .env.development .
COPY .sequelizerc .
EXPOSE 3000
CMD ["npm", "run", "dev"]

25
Dockerfile.production Normal file
View File

@ -0,0 +1,25 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm install --silent
COPY src ./src
COPY public ./public
COPY db ./db
COPY configs ./configs
COPY bin ./bin
COPY tsconfig.json .
COPY .env.production .
COPY .sequelizerc .
RUN npm run build
EXPOSE 3000
RUN chmod +x ./bin/start_server.sh
CMD ["sh", "./bin/start_server.sh"]

7
LICENSE Normal file
View File

@ -0,0 +1,7 @@
Copyright 2024 Mathieu Lussier
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# node-api-template
node-api-template is a micro service api template.
## Installation
Todo
## Todo
Application:
- [ ] Create an error handler wrapper for all the routes.
- [ ] On 1.0.0 release, finish the docker file and docker-compose file.
- [ ] Create tests using samples data
- [ ] Create seed data
Caching:
- [ ] Create a base interface for caching
- [ ] Create logics to invalidate cached data
Authentication:
- [x] Implement users authentication, registration, login.
- [x] Implement api key functionality for the users.
- [ ] Implement a role based access control for the users.
- [ ] Secure all the content and action based on roles.
- [ ] Secure all the important routes with a middleware based on roles.

33
bin/node-api-template Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env node
require('@configs/env.config')('production');
const logger = require('../dist/utils/logger.util').default;
const App = require('../dist/app').default;
const app = new App().app;
const port = process.env.PORT || 3000;
const server = app.listen(port, () => {
logger.info(`Listening on port ${port}`);
});
const exit = () => {
if (server) server.close();
logger.info('server close');
process.exit(0);
};
const unexpectedError = (error) => {
logger.error(error);
exit();
};
process.on('uncaughtException', unexpectedError);
process.on('unhandledRejection', unexpectedError);
process.on('SIGTERM', () => {
logger.info('SIGTERM received');
exit();
});
module.exports = server;

4
bin/start_server.sh Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
npx sequelize db:migrate --env production
node --trace-deprecation -r ts-node/register/transpile-only -r tsconfig-paths/register bin/node-api-template

View File

@ -0,0 +1,26 @@
{
"development": {
"username": "root",
"password": "",
"database": "node_api_template_development",
"host": "127.0.0.1",
"port": 5432,
"dialect": "postgres"
},
"test": {
"username": "root",
"password": "",
"database": "node_api_template_test",
"host": "127.0.0.1",
"port": 5432,
"dialect": "postgres"
},
"production": {
"username": "root",
"password": "",
"database": "node_api_template_production",
"host": "127.0.0.1",
"port": 5432,
"dialect": "postgres"
}
}

9
configs/env.config.js Normal file
View File

@ -0,0 +1,9 @@
const dotenv = require('dotenv');
const path = require('path');
module.exports = function (environment = 'development') {
const envFileName = `.env.${environment}`;
dotenv.config({
path: path.resolve(process.cwd(), envFileName),
});
};

44
configs/nginx.conf Normal file
View File

@ -0,0 +1,44 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream api-server {
server server:3000;
keepalive 100;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://api-server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}

0
db/migrations/.gitkeep Normal file
View File

View File

@ -0,0 +1,63 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
type: Sequelize.DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
username: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
unique: true,
},
email: {
type: Sequelize.DataTypes.STRING,
allowNull: true,
unique: true,
validate: {
isEmail: true,
},
},
password: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
},
strategy: {
type: Sequelize.DataTypes.STRING,
allowNull: false,
defaultValue: 'local',
},
identifier: {
type: Sequelize.DataTypes.STRING,
allowNull: true,
},
active: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.DataTypes.NOW,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.DataTypes.NOW,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: true,
},
});
},
async down(queryInterface) {
await queryInterface.dropTable('users');
},
};

0
db/seeds/.gitkeep Normal file
View File

0
doc/.gitkeep Normal file
View File

1
doc/docker.md Normal file
View File

@ -0,0 +1 @@
docker compose --env-file .env.production -f docker-compose.production.yml config >> docker-compose.yml

30
doc/sequelize.md Normal file
View File

@ -0,0 +1,30 @@
# List all available CLI commands
sequelize --list
# I can --env to specify the environment
# References
sequelize db:migrate | Run pending migrations
sequelize db:migrate:schema:timestamps:add | Update migration table to have timestamps
sequelize db:migrate:status | List the status of all migrations
sequelize db:migrate:undo | Reverts a migration
sequelize db:migrate:undo:all | Revert all migrations ran
sequelize db:seed | Run specified seeder
sequelize db:seed:undo | Deletes data from the database
sequelize db:seed:all | Run every seeder
sequelize db:seed:undo:all | Deletes data from the database
sequelize db:create | Create database specified by configuration
sequelize db:drop | Drop database specified by configuration
sequelize init | Initializes project
sequelize init:config | Initializes configuration
sequelize init:migrations | Initializes migrations
sequelize init:models | Initializes models
sequelize init:seeders | Initializes seeders
sequelize migration:generate | Generates a new migration file
sequelize migration:create | Generates a new migration file
sequelize model:generate | Generates a model and its migration
sequelize model:create | Generates a model and its migration
sequelize seed:generate | Generates a new seed file
sequelize seed:create | Generates a new seed file

View File

@ -0,0 +1,68 @@
version: '3.9'
services:
redis:
container_name: redis
image: redis:6.2.14-alpine
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
timeout: 10s
retries: 10
ports:
- 6379:6379
expose:
- 6379
restart: always
networks:
- x_trait_network
server:
container_name: server
image: pandrive:0.1.0
volumes:
- .:/app
ports:
- 3000:3000
expose:
- 3000
restart: always
networks:
- x_trait_network
links:
- database
- redis
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
database:
container_name: database
image: mysql:8
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "database" ]
timeout: 10s
retries: 10
cap_add:
- SYS_NICE
ports:
- 3306:3306
expose:
- 3306
volumes:
- db:/var/lib/mysql
restart: always
networks:
- x_trait_network
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
networks:
x_trait_network:
driver: bridge
volumes:
db:
driver: local

View File

@ -0,0 +1,85 @@
version: '3.9'
services:
proxy:
container_name: proxy
image: nginx:alpine
ports:
- 80:80
- 443:443
expose:
- 80
- 443
volumes:
- ./configs/nginx.conf:/etc/nginx/nginx.conf
restart: always
networks:
- x_trait_network
depends_on:
- server
links:
- server
redis:
container_name: redis
image: redis:6.2.14-alpine
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
timeout: 10s
retries: 10
ports:
- 6379:6379
expose:
- 6379
restart: always
networks:
- x_trait_network
server:
container_name: server
image: pandrive:0.1.0
ports:
- 3000:3000
expose:
- 3000
restart: always
networks:
- x_trait_network
links:
- database
- redis
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
database:
container_name: database
image: mysql:8
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "database" ]
timeout: 10s
retries: 10
cap_add:
- SYS_NICE
ports:
- 3306:3306
expose:
- 3306
volumes:
- db:/var/lib/mysql
restart: always
networks:
- x_trait_network
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
networks:
x_trait_network:
driver: bridge
volumes:
db:
driver: local

26
ecosystem.config.cjs Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
apps: [
{
name: 'app name',
script: 'npm',
args: 'start',
},
],
// Deployment Configuration
deploy: {
// production: {
// key: '~/.ssh/id_rsa',
// user: 'debian',
// host: ['192.99.34.69'],
// ref: 'origin/master',
// repo: 'repo.git',
// path: '/home/debian/appname',
// env: {
// NODE_ENV: 'production',
// },
// 'post-deploy':
// 'npm ci --production && npx sequelize db:migrate --env production && pm2 startOrRestart ecosystem.config.cjs --env production',
// },
},
};

20
jest.config.js Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
roots: ['<rootDir>/src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
testEnvironment: 'node',
preset: 'ts-jest',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'^@controllers/(.*)$': '<rootDir>/src/controllers/$1',
'^@models/(.*)$': '<rootDir>/src/models/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@libs/(.*)$': '<rootDir>/src/libs/$1',
'^@configs/(.*)$': '<rootDir>/configs/$1',
'^@middlewares/(.*)$': '<rootDir>/src/middlewares/$1',
},
};

6
nodemon.json Normal file
View File

@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts,json,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "npx ts-node --project ./tsconfig.json --transpileOnly src/server.ts"
}

10556
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
package.json Normal file
View File

@ -0,0 +1,99 @@
{
"name": "node-api-template",
"version": "0.1.0",
"description": "node-api-template is a micro service api template.",
"main": "bin/node-api-template",
"scripts": {
"start": "node --trace-deprecation -r ts-node/register/transpile-only -r tsconfig-paths/register bin/node-api-template",
"dev": "npx nodemon",
"build": "npx rimraf dist && npx tsc",
"clean": "npx rimraf dist",
"skip:postinstall": "npx sequelize db:migrate",
"db:setup:production": "npx sequelize db:create --env production && npx sequelize db:migrate --env production && npx sequelize db:seed:all --env production",
"test": "npx jest --forceExit"
},
"husky": {
"hooks": {
"pre-commit": "npx lint-staged"
}
},
"lint-staged": {
"*.ts": [
"npx prettier --write",
"npx eslint --fix",
"git add"
]
},
"repository": {
"type": "git",
"url": "git+https://github.com/MathieuLussier/node-apit-template.git"
},
"keywords": [
"api",
"template",
"express",
"node",
"micro service"
],
"author": "Mathieu Lussier",
"license": "MIT",
"bugs": {
"url": "https://github.com/MathieuLussier/node-apit-template/issues"
},
"homepage": "https://github.com/MathieuLussier/node-apit-template#readme",
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/morgan": "^1.9.9",
"@types/node": "^20.11.27",
"@types/redis": "^4.0.11",
"@types/sequelize": "^4.28.20",
"@types/supertest": "^6.0.2",
"@types/validator": "^13.11.9",
"@types/winston": "^2.4.4",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.11",
"jest": "^29.7.0",
"nodemon": "^3.0.3",
"prettier": "^3.2.5",
"rimraf": "^5.0.5",
"supertest": "^6.3.4",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
},
"dependencies": {
"axios": "^1.6.7",
"bcrypt": "^5.1.1",
"bullmq": "^5.2.1",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.4",
"express": "^4.18.2",
"express-validation": "^4.1.0",
"helmet": "^7.1.0",
"http-status": "^1.7.3",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"node-cron": "^3.0.3",
"pg": "^8.12.0",
"pg-hstore": "^2.3.4",
"pm2": "^5.3.1",
"redis": "^4.6.15",
"reflect-metadata": "^0.2.1",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-typescript": "^2.1.6",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"winston": "^3.13.0",
"winston-daily-rotate-file": "^5.0.0"
}
}

0
private/.gitkeep Normal file
View File

0
scripts/.gitkeep Normal file
View File

52
scripts/createUser.js Normal file
View File

@ -0,0 +1,52 @@
'use strict';
const bcrypt = require('bcrypt');
let env = 'development';
for (const [index, arg] of process.argv.entries()) {
if (arg === '--env') {
const e = process.argv[index + 1];
if (e === 'development' || e === 'production' || e === 'test') {
env = e;
} else {
console.error('Invalid environment');
process.exit(1);
}
break;
}
}
require('../configs/env.config')(env);
const User = require('../dist/models/user.model').default;
const Database = require('../dist/database').default;
async function main() {
const username = process.argv[2].trim();
const password = process.argv[3].trim();
console.log(username, password);
if (!username || !password) {
console.error('Username and password is required');
process.exit(1);
}
const connection = new Database().sequelize;
await connection.authenticate();
const hash = await bcrypt.hash(password, 10);
const user = new User({
username,
password: hash,
});
await user.save();
await connection.close();
process.exit(0);
}
main();

39
scripts/generateToken.js Normal file
View File

@ -0,0 +1,39 @@
'use strict';
const jwt = require('jsonwebtoken');
const fs = require('fs');
let env = 'development';
for (const [index, arg] of process.argv.entries()) {
if (arg === '--env') {
const e = process.argv[index + 1];
if (e === 'development' || e === 'production' || e === 'test') {
env = e;
} else {
console.error('Invalid environment');
process.exit(1);
}
break;
}
}
require('../configs/env.config')(env);
async function main() {
const secret = process.env.JWT_TOKEN_SECRET;
const token = await jwt.sign({ use: 'pandrive' }, secret, {
expiresIn: 86400, // expires in 24 hours
});
if (!fs.existsSync('tmp')) {
fs.mkdirSync('tmp');
}
fs.writeFileSync('tmp/token.txt', token);
console.log(token);
process.exit(0);
}
main();

View File

@ -0,0 +1,56 @@
require('@configs/env.config')('test');
import supertest from 'supertest';
import App from '@src/app';
import { Server } from 'http';
import Database from '@src/database';
let app;
let server: Server;
let response: supertest.Response;
const dbConnection = new Database().sequelize;
describe('GET /v1 should return message that contain "Node api" in json format', () => {
beforeAll((done) => {
app = new App().app;
const opening = Promise.all([
app.listen(3001),
dbConnection.authenticate(),
]);
opening.then((values) => {
server = values[0];
supertest(server)
.get('/v1')
.then((res) => {
response = res;
done();
});
});
});
afterAll((done) => {
const closing = Promise.all([server.close(), dbConnection.close()]);
closing.then(() => done());
});
it('should return status code 200', (done) => {
expect(response.status).toBe(200);
done();
});
it('should return json format', (done) => {
expect(response.type).toBe('application/json');
done();
});
it('should return object with message property', (done) => {
expect(response.body).toHaveProperty('message');
done();
});
it('should return object with message property to contain "Node api"', (done) => {
expect(response.body.message).toContain('Node api');
done();
});
});

View File

@ -0,0 +1,70 @@
require('@configs/env.config')('test');
import Database from '@src/database';
const connection = new Database().sequelize;
// Tests are the bare minimum to check if the models are working as expected
// TODO: Add more tests to check if the models are working as expected (hooks, constraints, cascade, etc)
describe.skip('It should be able to create different models data', () => {
beforeAll((done) => {
const opening = Promise.all([connection.authenticate()]);
opening
.then(async () => {
await connection.sync({ force: true });
done();
})
.catch((error) => {
console.error(error);
throw error;
});
});
afterAll((done) => {
const closing = Promise.all([connection.close()]);
closing.then(() => done());
});
it('It should be able to create a user', async () => {
// const t = await connection.transaction();
// try {
// const person = await PipedrivePerson.create(
// {
// pipedrive_id: 1,
// name: 'Mathieu Lussier',
// first_name: 'Mathieu',
// last_name: 'Lussier',
// function: 'Developer',
// infos: [
// { label: 'email', value: 'mathieu@x-trait.com', primary: true },
// { label: 'phone', value: '+14388894324', primary: true },
// ],
// },
// { include: [PipedrivePersonInfos], transaction: t }
// );
// expect(person).toHaveProperty('id');
// expect(person).toHaveProperty('first_name', 'Mathieu');
// expect(person).toHaveProperty('last_name', 'Lussier');
// expect(person).toHaveProperty('name', 'Mathieu Lussier');
// expect(person).toHaveProperty('function', 'Developer');
// expect(person.infos).toHaveLength(2);
// expect(person.infos && person.infos[0]).toHaveProperty('label', 'email');
// expect(person.infos && person.infos[0]).toHaveProperty(
// 'value',
// 'mathieu@x-trait.com'
// );
// expect(person.infos && person.infos[0]).toHaveProperty('primary', true);
// expect(person.infos && person.infos[1]).toHaveProperty('label', 'phone');
// expect(person.infos && person.infos[1]).toHaveProperty(
// 'value',
// '+14388894324'
// );
// expect(person.infos && person.infos[1]).toHaveProperty('primary', true);
// await t.commit();
// } catch (error) {
// console.error(error);
// await t.rollback();
// throw error;
// }
});
});

107
src/app.ts Normal file
View File

@ -0,0 +1,107 @@
import 'reflect-metadata';
import path from 'path';
import morgan from 'morgan';
import logger from '@utils/logger.util';
import cookieParser from 'cookie-parser';
import compression from 'compression';
import helmet from 'helmet';
import cors from 'cors';
import cron from 'node-cron';
import express, { Application, Request, Response, NextFunction } from 'express';
import { errorConverter, errorHandler } from '@middlewares/error.middleware';
import { IndexController } from '@src/controllers/v1/index';
import { Api } from '@src/core/index';
import { ApiTask } from '@src/tasks/index';
import ApiError from './libs/apiError.lib';
import httpStatus from 'http-status';
export default class App {
public app: Application = express();
protected _routers = [new IndexController()];
protected _tasks = [new ApiTask()];
constructor() {
this.init();
}
middleware(): void {
const morganStream = {
write: (message: string) =>
logger.http(message.trim().replace(/"/g, "'")),
};
this.app.use(
process.env.NODE_ENV === 'production'
? morgan('short', { stream: morganStream })
: morgan('dev', { stream: morganStream })
);
this.app.use(express.static(path.join(__dirname, '..', '/public')));
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
this.app.use(cookieParser());
this.app.use(compression());
this.app.use(helmet());
this.app.use(cors());
}
routes(): void {
for (const router of this._routers) {
this.app.use(router.router);
}
}
errorHandling(): void {
this.app.use((_req: Request, res: Response, next: NextFunction) => {
next(new ApiError('Not Found', httpStatus.NOT_FOUND));
});
this.app.use(
(err: any, _req: Request, res: Response, _next: NextFunction) => {
// const convertedError = errorConverter(err, _req, res);
return errorHandler(err, _req, res);
}
);
}
initCore(): void {
if (process.env.NODE_ENV === 'test') {
return;
}
new Api();
}
initCron(): void {
if (process.env.NODE_ENV !== 'production') {
return;
}
for (const task of this._tasks) {
if (!task.cronTasks) {
continue;
}
for (const cronTask of task.cronTasks) {
const { schedule, method } = cronTask;
cron.schedule(schedule, method.bind(task), {
scheduled: true,
timezone: 'America/Toronto',
});
}
}
}
init(): void {
this.app.set('trust proxy', true);
this.middleware();
this.routes();
this.errorHandling();
this.initCore();
this.initCron();
}
}

View File

@ -0,0 +1,44 @@
import { Router, Request, Response, NextFunction } from 'express';
import status from 'http-status';
import logger from '@utils/logger.util';
export interface IRoute {
method: string;
route: string;
action: (req: Request, res: Response, next: NextFunction) => void;
middlewares?: ((req: Request, res: Response, next: NextFunction) => void)[];
}
/**
* @class BaseController
* @description Base controller class to be extended by other controllers
*/
export default abstract class BaseController {
protected _router = Router();
private _routes: IRoute[];
public baseRoute: string;
protected status = status;
protected logger = logger;
constructor() {
this._routes ||= [];
this.baseRoute ||= `/${Object.getPrototypeOf(this).constructor.name.replace('Controller', '').toLowerCase()}`;
for (const route of this.routes) {
const formatRoute = (this.baseRoute + route.route).replace(/\/\//g, '/');
const handlers = route.middlewares
? [...route.middlewares, route.action.bind(this)]
: [route.action.bind(this)];
(this._router as any)[route.method](formatRoute, ...handlers);
}
}
get router() {
return this._router;
}
get routes() {
return this._routes;
}
}

3
src/base/core.base.ts Normal file
View File

@ -0,0 +1,3 @@
export default abstract class BaseCore {
constructor() {}
}

70
src/base/queue.base.ts Normal file
View File

@ -0,0 +1,70 @@
import { Queue, Job, Worker } from 'bullmq';
import logger from '@src/utils/logger.util';
export default abstract class BaseQueue {
private name: string;
protected queue: Queue;
protected worker: Worker;
protected redisPort =
typeof process.env.REDIS_PORT === 'undefined'
? 6379
: +process.env.REDIS_PORT;
protected redisConnectionObj = {
connection: { host: process.env.REDIS_HOST, port: this.redisPort },
};
constructor(name: string) {
this.name = name;
this.queue = new Queue(name, this.redisConnectionObj);
this.worker = new Worker(
name,
this.processJob.bind(this),
this.redisConnectionObj
);
this.worker.on('completed', this.jobCompleted);
this.worker.on('failed', this.jobFailed);
}
async jobCompleted(job, result) {
logger.info(
`[${this.name.toUpperCase()}] Job ${job.id} completed with result: ${result} for queue: ${job.queue.name}`
);
}
async jobFailed(job, err) {
console.error(err);
logger.error(
`[${this.name.toUpperCase()}] Job ${job.id} failed with error: ${err} for queue: ${job.queue.name}`
);
}
async addJob(jobName: string, data: any, delay: number = 0) {
await this.queue.add(jobName, data, {
attempts: 3,
delay,
backoff: {
type: 'exponential',
delay: 60000,
},
removeOnComplete: {
age: 3600, // keep up to 1 hour
count: 1000, // keep up to 1000 jobs
},
removeOnFail: {
age: 24 * 3600, // keep up to 24 hours
},
});
}
async addBulkJobs(jobs: { name: string; data: any }[]) {
await this.queue.addBulk(jobs);
}
abstract processJob(job: Job): Promise<any>;
async close() {
await this.worker.close();
await this.queue.close();
}
}

43
src/base/task.base.ts Normal file
View File

@ -0,0 +1,43 @@
import logger from '@utils/logger.util';
import { Transaction } from 'sequelize';
type TaskMethod = () => void;
export default abstract class TaskBase {
protected logger = logger;
private _cronTasks: { schedule: string; method: TaskMethod }[];
constructor() {
this._cronTasks ||= [];
}
get cronTasks() {
return this._cronTasks;
}
}
interface ITaskHandler {
setNext(task: TaskHandler): TaskHandler;
run(t: Transaction): void;
}
export abstract class TaskHandler implements ITaskHandler {
protected logger = logger;
private nextTask?: TaskHandler;
constructor() {}
public setNext(task: TaskHandler): TaskHandler {
this.nextTask = task;
return task;
}
public async run(t): Promise<void> {
if (this.nextTask) {
return this.nextTask.run(t);
}
return;
}
}

View File

@ -0,0 +1,121 @@
import { NextFunction, Request, Response } from 'express';
import BaseController from '@src/base/controller.base';
import { Controller, Get, Post } from '@src/decorators/controller.decorator';
import { version } from '../../../package.json';
import Database from '@src/database';
import { validateLoginRequest } from '@src/middlewares/auth.middleware';
import UserService from '@src/services/user.service';
import { User } from '@src/models';
import ApiError from '@src/libs/apiError.lib';
import httpStatus from 'http-status';
import jwt from 'jsonwebtoken';
@Controller('/v1')
export default class IndexController extends BaseController {
private connection;
constructor() {
super();
this.connection = new Database().sequelize;
this.connection.authenticate();
}
@Get('/')
public getIndex(req: Request, res: Response) {
return res.status(this.status.OK).json({ message: `Node api v${version}` });
}
@Post('/refresh-token')
public async postRefreshToken(
req: Request,
res: Response,
next: NextFunction
) {
const refreshToken = req.body.refreshToken;
if (!refreshToken) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
try {
const decoded = await jwt.verify(
refreshToken,
process.env.JWT_REFRESH_TOKEN_SECRET
);
const user = await UserService.getInstance().getUserById(decoded.id);
if (!user) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
const data = {
id: user.id,
username: user.username,
email: user.email,
};
const accessToken = await jwt.sign(data, process.env.JWT_TOKEN_SECRET, {
expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRES_IN,
});
return res.status(this.status.OK).json({
message: 'Token refreshed',
statusCode: this.status.OK,
accessToken,
});
} catch (error) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
}
@Post('/login', [validateLoginRequest])
public async postLogin(req: Request, res: Response, next: NextFunction) {
const { username, password } = req.body;
const foundUser = (await UserService.getInstance().getUserByUsername(
username
)) as User;
if (!foundUser) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
const isPasswordValid = await foundUser.comparePassword(password);
if (!isPasswordValid) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
const data = {
id: foundUser.id,
username: foundUser.username,
email: foundUser.email,
};
const accessToken = await jwt.sign(data, process.env.JWT_TOKEN_SECRET, {
expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRES_IN,
});
const refreshToken = await jwt.sign(
{ id: foundUser.id },
process.env.JWT_REFRESH_TOKEN_SECRET,
{
expiresIn: process.env.JWT_REFRESH_TOKEN_EXPIRES_IN,
}
);
return res.status(this.status.OK).json({
message: 'Login successful',
statusCode: this.status.OK,
accessToken,
refreshToken,
});
}
}

View File

@ -0,0 +1 @@
export { default as IndexController } from './index.controller';

11
src/core/api.ts Normal file
View File

@ -0,0 +1,11 @@
import BaseCore from '@src/base/core.base';
import logger from '@src/utils/logger.util';
export default class Api extends BaseCore {
constructor() {
super();
logger.info('[API] Initialized');
}
}

3
src/core/index.ts Normal file
View File

@ -0,0 +1,3 @@
import { default as Api } from './api';
export { Api };

60
src/database.ts Normal file
View File

@ -0,0 +1,60 @@
import { Sequelize } from 'sequelize-typescript';
import logger from './utils/logger.util';
interface DatabaseConfig {
development: {
username: string;
password: string | undefined;
database: string;
host: string;
dialect: string;
};
test: {
username: string;
password: string | undefined;
database: string;
host: string;
dialect: string;
};
production: {
username: string;
password: string | undefined;
database: string;
host: string;
dialect: string;
};
[key: string]: {
username: string;
password: string | undefined;
database: string;
host: string;
dialect: string;
};
}
const config: DatabaseConfig = require('../configs/database.json');
const node_env = process.env.NODE_ENV || 'development';
const envConfig = config[node_env];
const sequelizeOptions: any & { dialect: any } = {
...envConfig,
models: [__dirname + '/models/**/*.model.*'],
logging: (msg) => logger.debug(msg),
dialect: envConfig.dialect,
define: {
charset: 'utf8',
collate: 'utf8_general_ci',
},
};
export default class Database {
private _sequelize: Sequelize;
constructor() {
this._sequelize = new Sequelize(sequelizeOptions);
}
get sequelize(): Sequelize {
return this._sequelize;
}
}

View File

@ -0,0 +1,46 @@
import { Request, Response, NextFunction } from 'express';
function action(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
method: string,
route: string,
middlewares?: ((req: Request, res: Response, next: NextFunction) => void)[]
) {
if (!target._routes) {
target._routes = [];
}
target._routes.push({
method,
route,
action: target[propertyKey],
middlewares,
});
}
function createRouteDecorator(method: string) {
return (
route: string,
middlewares?: ((req: Request, res: Response, next: NextFunction) => void)[]
) => {
return (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) => {
action(target, propertyKey, descriptor, method, route, middlewares);
};
};
}
export const Get = createRouteDecorator('get');
export const Post = createRouteDecorator('post');
export const Put = createRouteDecorator('put');
export const Delete = createRouteDecorator('delete');
export function Controller(baseRoute: string) {
return (target: any) => {
target.prototype.baseRoute = baseRoute;
};
}

View File

@ -0,0 +1,29 @@
import cron from 'node-cron';
export function Cron(schedule: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
if (!target._cronTasks) {
target._cronTasks = [];
}
const originalMethod = descriptor.value;
const valid = cron.validate(schedule);
if (!valid) {
throw new Error(
`[CRON] Invalid cron schedule ${schedule} for ${propertyKey}`
);
}
target._cronTasks.push({
schedule,
method: originalMethod,
});
return descriptor;
};
}

25
src/events/api.event.ts Normal file
View File

@ -0,0 +1,25 @@
import EventEmitter from 'events';
export enum ApiEvents {
EVENT_NAME = 'event_name',
}
export default class ApiEvent extends EventEmitter {
private static instance: ApiEvent;
constructor() {
super();
}
public static getInstance() {
if (!ApiEvent.instance) {
ApiEvent.instance = new ApiEvent();
}
return ApiEvent.instance;
}
public subscribe(eventName: ApiEvents, callback: (_data: any) => void) {
this.on(eventName, callback);
}
}

34
src/jobs/api.job.ts Normal file
View File

@ -0,0 +1,34 @@
import BaseQueue from '../base/queue.base';
import { Job } from 'bullmq';
import logger from '@src/utils/logger.util';
export default class ApiQueue extends BaseQueue {
static instance: ApiQueue;
constructor() {
super('apiQueue');
}
static getInstance() {
if (!this.instance) {
this.instance = new ApiQueue();
}
return this.instance;
}
async processJob(job: Job) {
switch (job.name) {
case 'jobExample':
return this.jobExample(job);
default:
logger.error(`[CRON] Job name not found: ${job.name}`);
}
}
async jobExample(job: Job) {
logger.info(`[CRON] Running job example: ${job.id}`);
return 'ok';
}
}

15
src/libs/apiError.lib.ts Normal file
View File

@ -0,0 +1,15 @@
export default class ApiError extends Error {
public statusCode: number;
public isPublic: boolean;
constructor(message, statusCode, isPublic = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isPublic = isPublic;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}

106
src/libs/composite.lib.ts Normal file
View File

@ -0,0 +1,106 @@
abstract class CompositeComponent {
protected parent!: CompositeComponent | null;
public setParent(parent: CompositeComponent | null) {
this.parent = parent;
}
public getParent(): CompositeComponent | null {
return this.parent;
}
public add(component: CompositeComponent): void {}
public remove(component: CompositeComponent): void {}
public isComposite(): boolean {
return false;
}
abstract getByField(field: string, name: string): CompositeComponent | null;
abstract getValueByField(
field: string,
name: string
): string | number | boolean | null;
}
export class CompositeData extends CompositeComponent {
private _name: string;
private key: string;
private value: string | number | boolean;
constructor(key: string, value: string | number | boolean, name: string) {
super();
this._name = name;
this.key = key;
this.value = value;
}
public getByField(field: string, name = ''): CompositeComponent | null {
if (field === this.key && name !== '' && name === this._name) {
return this;
}
return null;
}
public getValueByField(
field: string,
name = ''
): string | number | boolean | null {
if (field === this.key && name !== '' && name === this._name) {
return this.value;
}
return null;
}
}
export default class Composite extends CompositeComponent {
protected children: CompositeComponent[] = [];
public add(component: CompositeComponent): void {
this.children.push(component);
component.setParent(this);
}
public remove(component: CompositeComponent): void {
const componentIndex = this.children.indexOf(component);
this.children.splice(componentIndex, 1);
component.setParent(null);
}
public isComposite(): boolean {
return true;
}
public get length(): number {
return this.children.length;
}
public getByField(field: string, name = ''): CompositeComponent | null {
for (const child of this.children) {
const result = child.getByField(field, name);
if (result) {
return result;
}
}
return null;
}
public getValueByField(
field: string,
name = ''
): string | number | boolean | null {
for (const child of this.children) {
const result = child.getValueByField(field, name);
if (result) {
return result;
}
}
return null;
}
}

View File

@ -0,0 +1,39 @@
import { Request, Response, NextFunction } from 'express';
import { default as crypto } from 'crypto';
import { validate, Joi } from 'express-validation';
import ApiError from '@src/libs/apiError.lib';
export const validateWebhook = validate(
{
body: Joi.array().items(
Joi.object({
event: Joi.string().required(),
data: Joi.object().required(),
})
),
},
{},
{}
);
export const validateWebhookSignature = async function (
req: Request,
res: Response,
next: NextFunction
) {
const sharedKey = process.env.WEBHOOK_SHARED_KEY || '';
const requestSignature = req.query.signature;
const signature = await crypto
.createHmac('sha256', sharedKey)
.update(JSON.stringify(req.body))
.digest('hex');
if (requestSignature !== signature) {
next(new ApiError('Invalid signature', 403));
return;
}
next();
};

View File

@ -0,0 +1,83 @@
import { Request, Response, NextFunction } from 'express';
import ApiError from '@src/libs/apiError.lib';
import { verify } from 'jsonwebtoken';
import logger from '@src/utils/logger.util';
import httpStatus from 'http-status';
import { User } from '@src/models';
import UserService from '@src/services/user.service';
import { validate, Joi } from 'express-validation';
export const validateLoginRequest = validate(
{
body: Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}),
},
{
keyByField: true,
},
{}
);
export const checkApiToken = async (
req: Request,
res: Response,
next: NextFunction
) => {
const token = req.headers['x-api-key'];
const secretKey = process.env.JWT_TOKEN_SECRET || '';
if (!token) {
next(
new ApiError('Token missing in header x-api-key', httpStatus.FORBIDDEN)
);
return;
}
try {
await verify(token as string, secretKey);
} catch (error) {
logger.debug('Error in checkApiToken', error);
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
next();
};
export const basicAuth = async (
req: Request,
res: Response,
next: NextFunction
) => {
const auth = req.headers.authorization;
if (!auth) {
next(new ApiError('Forbidden', httpStatus.FORBIDDEN));
return;
}
const [username, password] = Buffer.from(auth.split(' ')[1], 'base64')
.toString()
.split(':');
const foundUser = (await UserService.getInstance().getUserByUsername(
username
)) as User;
if (!foundUser) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
const isPasswordValid = await foundUser.comparePassword(password);
if (!isPasswordValid) {
next(new ApiError('Unauthorized', httpStatus.UNAUTHORIZED));
return;
}
next();
};

View File

@ -0,0 +1,52 @@
import ApiError from '@src/libs/apiError.lib';
import { InstanceError } from 'sequelize';
import { ValidationError } from 'express-validation';
import httpStatus from 'http-status';
import logger from '@src/utils/logger.util';
export const errorConverter = (err, req, res) => {
let error = err;
if (!(error instanceof ApiError || error instanceof ValidationError)) {
const statusCode =
error.statusCode || error instanceof InstanceError
? httpStatus.BAD_REQUEST
: httpStatus.INTERNAL_SERVER_ERROR;
const message = error.message || httpStatus[statusCode];
error = new ApiError(
statusCode,
message
// process.env.NODE_ENV === 'production',
// err.stack
);
}
return error;
};
export const errorHandler = (err, req, res) => {
let { statusCode, message } = err;
statusCode = err.statusCode || httpStatus.INTERNAL_SERVER_ERROR;
message = err.message || httpStatus[httpStatus.INTERNAL_SERVER_ERROR];
// if (process.env.NODE_ENV === 'production' && !err.isPublic) {
// statusCode = err.statusCode || httpStatus.INTERNAL_SERVER_ERROR;
// message = err.message || httpStatus[httpStatus.INTERNAL_SERVER_ERROR];
// }
const response: {
statusCode: any;
message: any;
stack?: any;
details?: any;
} = {
statusCode,
message,
// ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
};
if (err instanceof ValidationError) response.details = err.details;
if (statusCode === httpStatus.INTERNAL_SERVER_ERROR) {
logger.error(err);
}
return res.status(statusCode).json(response);
};

3
src/models/index.ts Normal file
View File

@ -0,0 +1,3 @@
import User from './user.model';
export { User };

36
src/models/user.model.ts Normal file
View File

@ -0,0 +1,36 @@
import { Table, Column, Model } from 'sequelize-typescript';
import { compare } from 'bcrypt';
@Table({ tableName: 'users', timestamps: true, paranoid: true })
export default class User extends Model {
@Column({ unique: true, allowNull: false })
username!: string;
@Column({ unique: true, allowNull: true, validate: { isEmail: true } })
email!: string;
@Column({ allowNull: false })
password!: string;
@Column({ allowNull: false, defaultValue: 'local' })
strategy!: string;
@Column({ allowNull: true })
identifier!: string;
@Column({ allowNull: false, defaultValue: true })
active!: boolean;
// @BeforeCreate
// @BeforeUpdate
// static async hashPassword(instance: User) {
// if (instance.changed('password')) {
// instance.password = await hash(instance.password, 10);
// }
// }
async comparePassword(password: string) {
return await compare(password, this.password);
}
}

18
src/redis.ts Normal file
View File

@ -0,0 +1,18 @@
import { createClient } from 'redis';
export default class Redis {
private static instance: Redis;
protected client = createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
constructor() {}
static getInstance(): Redis {
if (!Redis.instance) {
Redis.instance = new Redis();
}
return Redis.instance;
}
}

13
src/server.ts Normal file
View File

@ -0,0 +1,13 @@
require('@configs/env.config')('development');
import logger from './utils/logger.util';
import App from './app';
const port = process.env.PORT || 3000;
const app = new App().app;
const server = app.listen(port, () => {
logger.info(`Listening on port ${port}`);
});
export default server;

View File

@ -0,0 +1,27 @@
import Database from '@src/database';
import { User } from '@src/models';
export default class UserService {
static instance: UserService;
private connection = new Database().sequelize;
constructor() {
this.connection.authenticate();
}
public static getInstance(): UserService {
if (!this.instance) {
this.instance = new UserService();
}
return this.instance;
}
public async getUserByUsername(username: string): Promise<User | null> {
return await User.findOne({ where: { username } });
}
public async getUserById(id: string): Promise<User | null> {
return await User.findByPk(id);
}
}

13
src/tasks/api.task.ts Normal file
View File

@ -0,0 +1,13 @@
import TaskBase from '@src/base/task.base';
import { Cron } from '@src/decorators/task.decorator';
export default class ApiTask extends TaskBase {
constructor() {
super();
}
@Cron('0 0 * * *')
public async run() {
this.logger.info('[APITASK] Example task running at midnight...');
}
}

3
src/tasks/index.ts Normal file
View File

@ -0,0 +1,3 @@
import { default as ApiTask } from './api.task';
export { ApiTask };

0
src/types/.gitkeep Normal file
View File

9
src/utils/index.ts Normal file
View File

@ -0,0 +1,9 @@
import logger from './logger.util';
import { ApiRateLimiter } from './rate-limiter.util';
import SequelizeUtil from './sequelize.util';
module.exports = {
logger,
ApiRateLimiter,
SequelizeUtil,
};

97
src/utils/logger.util.ts Normal file
View File

@ -0,0 +1,97 @@
import fs from 'fs';
import path from 'path';
import { createLogger, transports, format } from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
function filterOnly(level: string) {
return format((info) => {
if (info.level === level) {
return info;
}
return false;
})();
}
function excludeLevels(levels: string[]) {
return format((info) => {
if (levels.includes(info.level)) {
return false;
}
return info;
})();
}
const logDirectory = path.resolve(process.cwd(), 'logs');
if (!fs.existsSync(logDirectory)) {
fs.mkdirSync(logDirectory);
}
const dailyRotateTransport = new DailyRotateFile({
filename: `${logDirectory}/%DATE%/logs-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
format: format.combine(
excludeLevels(['http', 'error']),
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
format.errors({ stack: true }),
format.splat(),
format.json()
),
});
const httpRotateTransport = new DailyRotateFile({
level: 'http',
filename: `${logDirectory}/%DATE%/http-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
format: format.combine(
filterOnly('http'),
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
format.errors({ stack: true }),
format.splat(),
format.json()
),
});
const errorDailyRotateTransport = new DailyRotateFile({
level: 'error',
filename: `${logDirectory}/%DATE%/error-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
format: format.combine(
filterOnly('error'),
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
format.errors({ stack: true }),
format.splat(),
format.json()
),
});
const logger = createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
silent: process.env.NODE_ENV === 'test',
format: format.combine(
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
format.errors({ stack: true }),
format.splat(),
format.json()
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({ level, message }) => `${level} ${message}`)
),
}),
errorDailyRotateTransport,
httpRotateTransport,
dailyRotateTransport,
],
});
export default logger;

View File

@ -0,0 +1,38 @@
import { AxiosInstance } from 'axios';
import logger from './logger.util';
interface IRateLimiter {
handle(...args: any): any;
}
abstract class RateLimiter implements IRateLimiter {
abstract delay: number;
sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
abstract handle(...args: any): any;
}
export class ApiRateLimiter extends RateLimiter {
delay = 5000;
public async handle(client: AxiosInstance, options: any = {}) {
try {
const res = await client.request(options);
return res;
} catch (error: any) {
if (error.response && error.response.status === 429) {
logger.info(`[API] Rate limit exceeded, waiting for ${this.delay}ms`);
await this.sleep(this.delay);
return this.handle(client, options);
}
logger.error(`[RATELIMIT] Error while handling request: ${error}`);
}
}
}

View File

@ -0,0 +1,22 @@
export type FindOrCreateOptions = {
where: any;
defaults?: any;
transaction?: any;
};
export async function findOrCreateAndUpdate(
model,
options: FindOrCreateOptions
) {
const [instance, created] = await model.findOrCreate(options);
if (!created) {
await instance.update(options.defaults);
}
return instance;
}
export default {
findOrCreateAndUpdate,
};

49
tsconfig.json Normal file
View File

@ -0,0 +1,49 @@
{
"ts-node": {
"require": ["tsconfig-paths/register"],
"transpileOnly": true
},
"compilerOptions": {
"types": ["node", "jest", "express"],
"target": "ES2021",
"lib": ["ES2021"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"module": "commonjs",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@src/*": ["src/*"],
"@controllers/*": ["src/controllers/*"],
"@models/*": ["src/models/*"],
"@services/*": ["src/services/*"],
"@utils/*": ["src/utils/*"],
"@libs/*": ["src/libs/*"],
"@configs/*": ["configs/*"],
"@middlewares/*": ["src/middlewares/*"],
"@events": ["src/events/*"],
"@base": ["src/base/*"],
"@content": ["src/content/*"],
},
"resolveJsonModule": true,
"allowJs": true,
"outDir": "dist/",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitAny": false,
"alwaysStrict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "configs", "bin", "public", "logs", "db", "src/server.ts"]
}