first commit
This commit is contained in:
commit
f1e1806f2b
21
.dockerignore
Normal file
21
.dockerignore
Normal 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
13
.env.template
Normal 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
35
.eslintrc.js
Normal 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
145
.gitignore
vendored
Normal 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
6
.prettierrc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
8
.sequelizerc
Normal file
8
.sequelizerc
Normal 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
23
.vscode/launch.json
vendored
Normal 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
21
Dockerfile.development
Normal 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
25
Dockerfile.production
Normal 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
7
LICENSE
Normal 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
26
README.md
Normal 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
33
bin/node-api-template
Normal 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
4
bin/start_server.sh
Normal 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
|
26
configs/database.template.json
Normal file
26
configs/database.template.json
Normal 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
9
configs/env.config.js
Normal 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
44
configs/nginx.conf
Normal 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
0
db/migrations/.gitkeep
Normal file
63
db/migrations/20240313200650-create-user.js
Normal file
63
db/migrations/20240313200650-create-user.js
Normal 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
0
db/seeds/.gitkeep
Normal file
0
doc/.gitkeep
Normal file
0
doc/.gitkeep
Normal file
1
doc/docker.md
Normal file
1
doc/docker.md
Normal 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
30
doc/sequelize.md
Normal 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
|
68
docker-compose.development.yml
Normal file
68
docker-compose.development.yml
Normal 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
|
85
docker-compose.production.yml
Normal file
85
docker-compose.production.yml
Normal 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
26
ecosystem.config.cjs
Normal 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
20
jest.config.js
Normal 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
6
nodemon.json
Normal 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
10556
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
package.json
Normal file
99
package.json
Normal 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
0
private/.gitkeep
Normal file
0
scripts/.gitkeep
Normal file
0
scripts/.gitkeep
Normal file
52
scripts/createUser.js
Normal file
52
scripts/createUser.js
Normal 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
39
scripts/generateToken.js
Normal 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();
|
56
src/__tests__/http/index.http.test.ts
Normal file
56
src/__tests__/http/index.http.test.ts
Normal 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();
|
||||
});
|
||||
});
|
70
src/__tests__/model/pipedrive-models.test.ts
Normal file
70
src/__tests__/model/pipedrive-models.test.ts
Normal 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
107
src/app.ts
Normal 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();
|
||||
}
|
||||
}
|
44
src/base/controller.base.ts
Normal file
44
src/base/controller.base.ts
Normal 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
3
src/base/core.base.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default abstract class BaseCore {
|
||||
constructor() {}
|
||||
}
|
70
src/base/queue.base.ts
Normal file
70
src/base/queue.base.ts
Normal 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
43
src/base/task.base.ts
Normal 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;
|
||||
}
|
||||
}
|
121
src/controllers/v1/index.controller.ts
Normal file
121
src/controllers/v1/index.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
1
src/controllers/v1/index.ts
Normal file
1
src/controllers/v1/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as IndexController } from './index.controller';
|
11
src/core/api.ts
Normal file
11
src/core/api.ts
Normal 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
3
src/core/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { default as Api } from './api';
|
||||
|
||||
export { Api };
|
60
src/database.ts
Normal file
60
src/database.ts
Normal 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;
|
||||
}
|
||||
}
|
46
src/decorators/controller.decorator.ts
Normal file
46
src/decorators/controller.decorator.ts
Normal 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;
|
||||
};
|
||||
}
|
29
src/decorators/task.decorator.ts
Normal file
29
src/decorators/task.decorator.ts
Normal 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
25
src/events/api.event.ts
Normal 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
34
src/jobs/api.job.ts
Normal 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
15
src/libs/apiError.lib.ts
Normal 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
106
src/libs/composite.lib.ts
Normal 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;
|
||||
}
|
||||
}
|
39
src/middlewares/api.middleware.ts
Normal file
39
src/middlewares/api.middleware.ts
Normal 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();
|
||||
};
|
83
src/middlewares/auth.middleware.ts
Normal file
83
src/middlewares/auth.middleware.ts
Normal 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();
|
||||
};
|
52
src/middlewares/error.middleware.ts
Normal file
52
src/middlewares/error.middleware.ts
Normal 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
3
src/models/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import User from './user.model';
|
||||
|
||||
export { User };
|
36
src/models/user.model.ts
Normal file
36
src/models/user.model.ts
Normal 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
18
src/redis.ts
Normal 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
13
src/server.ts
Normal 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;
|
27
src/services/user.service.ts
Normal file
27
src/services/user.service.ts
Normal 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
13
src/tasks/api.task.ts
Normal 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
3
src/tasks/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { default as ApiTask } from './api.task';
|
||||
|
||||
export { ApiTask };
|
0
src/types/.gitkeep
Normal file
0
src/types/.gitkeep
Normal file
9
src/utils/index.ts
Normal file
9
src/utils/index.ts
Normal 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
97
src/utils/logger.util.ts
Normal 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;
|
38
src/utils/rate-limiter.util.ts
Normal file
38
src/utils/rate-limiter.util.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
22
src/utils/sequelize.util.ts
Normal file
22
src/utils/sequelize.util.ts
Normal 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
49
tsconfig.json
Normal 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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user