initial commit

This commit is contained in:
evan.steele 2025-04-05 23:16:43 -07:00
commit 5ca1185c90
100 changed files with 30290 additions and 0 deletions

21
.circleci/config.yml Normal file
View File

@ -0,0 +1,21 @@
# This config is equivalent to both the '.circleci/extended/orb-free.yml' and the base '.circleci/config.yml'
version: 2.1
# Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects.
# See: https://circleci.com/docs/2.0/orb-intro/
orbs:
node: circleci/node@5.1.0
# Invoke jobs via workflows
# See: https://circleci.com/docs/2.0/configuration-reference/#workflows
workflows:
sample: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
jobs:
- node/test:
# This is the node version to use for the `cimg/node` tag
# Relevant tags can be found on the CircleCI Developer Hub
# https://circleci.com/developer/images/image/cimg/node
version: '18.14.2'
# If you are using yarn, change the line below from "npm" to "yarn"
pkg-manager: npm

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
build
README.md

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
insert_final_newline = true

67
.env.example Normal file
View File

@ -0,0 +1,67 @@
HOST=0.0.0.0
PORT=3000
SERVICE_NAME='your_service_name'
#JWT CONFIGURATION
JWT_KEY='your_secret'
SECRET='my_super_secret'
HASH=10
#JWT_PRIVATE_SECRET='jwt-private-secret'
#JWT_PUBLIC_SECRET='jwt-public-secret'
#GOOGLE CLOUD CONFIGURATION
#Go to GCP and create a service account and replace all the fields with yours in the json file
GOOGLE_APPLICATION_CREDENTIALS='./src/config/gcloud/google-application-credentials.json'
GOOGLE_PROJECT_ID='your_google_project_id'
GOOGLE_STORAGE_BUCKET_NAME='your_google_storage_bucket_name'
GOOGLE_CLIENT_ID='your_google_client_id'
GOOGLE_CLIENT_SECRET='your_google_client_secret'
GOOGLE_MAPS_API_KEY='your_google_maps_api_key'
#CLIENT CONFIGURATION
CLIENT_URL='your_client_url_to_authorize'
#MONGO DB CONFIGURATION
MONGO_URI='your_mongo_db_connection'
MONGO_URI_TEST='your_mongo_db_connection_test'
MONGO_USER='your_mongo_user'
MONGO_PASS='your_mongo_password'
#MYSQL CONFIGURATION
MYSQL_HOST_STAGE='your_myql_host_stage'
MYSQL_USER_STAGE='your_myql_user'
MYSQL_PASSWORD_STAGE='your_myql_pass'
MYSQL_DB_STAGE='your_myql_db_name'
MYSQL_SOCKET_STAGE='/your/socket-cloud-sql'
MYSQL_HOST_PROD='your_myql_host_stage'
MYSQL_USER_PROD='your_myql_user'
MYSQL_PASSWORD_PROD='your_myql_pass'
MYSQL_DB_PROD='your_myql_db_name'
MYSQL_SOCKET_PROD='/your/socket-cloud-sql'
#SPARKPOST CONFIGURATION
SPARKPOST_API_KEY='your_sparkpost_test_api_key'
#SPARKPOST_API_KEY='your_sparkpost_live_api_key'
SPARKPOST_SENDER_DOMAIN='your_sparkpost_sender_domain'
# MESSAGEBIRD CONFIGURATION
MESSAGEBIRD_ACCESS_KEY='your_messagbird_access_key' #test key
#MESSAGEBIRD_ACCESS_KEY='your_messagbird_access_key' #live key
MESSAGEBIRD_WHATSAPP_CHANNEL_ID='your_messagebird_whatsapp_channel_id'
MESSAGEBIRD_TEMPLATE_NAMESPACE_ID='your_messagebird_template_namespace_id'
#SENDGRID CONFIGURATION
SENDGRID_API_KEY='your_sendgrid_api_key'
SENDGRID_SENDER_EMAIL='your_sendgrid_email_sender'
#TWILIO CONFIGURATION
TWILIO_ACCOUNT_SID='your_twilio_account_sid'
TWILIO_AUTH_TOKEN='your_twilio_account_token'
TWILIO_PHONE_NUMBER='+your_phone_number'
#PUB/SUB TOPICS
TOPIC_NAME='your_pubbus_topic_name'
SUBSCRIPTION_NAME='your_pubsub_subscription_name'

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
build/
node_modules/
docs/

6
.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "./node_modules/gts/",
"rules": {
"no-process-exit": "off"
}
}

5
.gcloudignore Normal file
View File

@ -0,0 +1,5 @@
.gcloudignore
.git
.gitignore
node_modules/
#!include:.gitignore

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

140
.gitignore vendored Normal file
View File

@ -0,0 +1,140 @@
# 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
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# 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
.cache
# 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.*
build
docs
docs/
src/config/gcloud/google-web-client-secret.json
src/config/gcloud/google-application-credentials.json
target/

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
build
dist

3
.prettierrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
...require('gts/.prettierrc.json')
}

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to make participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when
an individual is representing the project or its community in public spaces.
Examples of representing a project or community include using an official
project e-mail address, posting via an official social media account, or acting
as an appointed representative at an online or offline event. Representation of
a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

9
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,9 @@
# Contributing to Project
All contributions are welcome!
For contributing to this project, please:
* fork the repository to your own account
* clone the repository
* make changes
* submit a pull request on `development` branch

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:18-alpine as base
WORKDIR /usr/src/app
EXPOSE 3000
FROM base as builder
COPY ["package.json", "package-lock.json*", "./"]
COPY ./tsconfig.json ./tsconfig.json
COPY ./src ./src
RUN npm ci --only-production
RUN npm run compile
RUN npm prune --production
FROM base as release
ENV NODE_ENV=production
USER node
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/build ./build
COPY --chown=node:node . /usr/src/app
CMD ["node", "./build/src/bin/server"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Giuseppe Albrizio
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 NON INFRINGEMENT. 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.

189
README.md Normal file
View File

@ -0,0 +1,189 @@
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/giuseppealbrizio/typescript-rest-api-backend/tree/main.svg?style=svg&circle-token=a73f0879b6f17258a912820c3082a572d49d4ff6)](https://dl.circleci.com/status-badge/redirect/gh/giuseppealbrizio/typescript-rest-api-backend/tree/main)
[![Code Style: Google](https://img.shields.io/badge/code%20style-google-blueviolet.svg)](https://github.com/google/gts)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://github.com/Envoy-VC/awesome-badges)
[![Kubernets](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://github.com/Envoy-VC/awesome-badges)
# Typescript REST API Backend Template
## Feel free to support this project
If you found this project helpful, please consider supporting me by buying me a coffee! Your support will help me to keep creating more useful content and improving this project.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/galbrizio)
---
#### Typescript REST microservice boilerplate using node.js and express and some other cool stuff
This template is intended to be used as single service in a REST multi-service application using Cloud Pub/Sub as
message broker
use in local with Skaffold and in cloud with GKE
To know more about how to implement GKE and run with Skaffold please refer to this folder:
`./infra`
The application uses express as framework and is configured with the following features:
- `ECMA2022` features enabled
- `Dotenv` Load environment variables from .env file
- `Eslint` Code quality tool
- `Prettier` to prettify the code
- `MongoDB` ready to go configuration with mongoose
- `MySQL` ready to go configuration with mysql2
- `CORS` feature enabled
- `RBAC` logic to authorize people with specific roles to use the endpoints.
- `Passport` logic to add an authentication layer if neeeded.
- `Sparkpost` email service support with sparkpost.
- `Error Handling` errors custom middleware and helpers globally configured
- `Multer` File uploading configured to use in routes as middleware
- `Google Cloud Storage` middleware configured to use Google Cloud Storage as upload bucket
- `Google Cloud Pub/Sub` pub/sub support for event driven events added
- `Axios` globally configured in `./src/utils/api.utils.js`
- `Swagger` documentation reachable at `http://localhost:3000/api/v1/docs`
- `Jest` Testing tool support
- `Logger` Logging support with Winston
- `Docker` ready configuration with multi-stage option
- `Terraform` ready configuration to instantiate infrastracture in GCP
- `Agenda` ready to emit events through agenda jobs
- `Best practices` in naming files
## Basic Information
- App entry point is located in `./src/index.ts`
- Server config entrypoint is located in `./src/bin/server.ts`
- Prettier config is located at `./.prettierrc.js`
- Eslint config is located at `./.eslintrc`
- Sparkpost service support is located at `./src/services/email/sparkport.service.ts`
- You can define your own email services in this file
- Mongo config is located at `./src/config/mongodb.config.ts`
- MYSQL config is located at `./src/config/mysql.config.ts`
- Error Handling middleware is located at `./src/middlewares/errorHandler.middleware.ts`
- You can configure as many errors you need in `./src/errors/`
- Multer middleware is located at `./src/middlewares/upload.middleware.ts`
- If you want to use Google Cloud Storage as upload bucket follow instructions at `./src/config/gcloud/README.md`
- RBAC logic middleware is located at `./src/middlewares/verifyApiRights.middleware.ts`
- Swagger config file is located at `./src/api/swagger/swagger.route.js`
- Swagger routes are defined in `./src/api/swagger/swagger.route.ts`
- Docker config is located at `./Dockerfile`
- Pub/Sub service is located at `./src/services/pubsub/pub-sub.service.js`
## Folder Structure
> `infra/`
>
> - **For more information about the k8s configuration please check the README file**
> - **`k8s`** - folder contains all production kubernetes manifests
> - **`k8s-dev`** - folder contains all development kubernetes manifests to run with skaffold
> - **`scripts`** - older contains all script related to the creation of a cluster or running skaffold or secret
> creation
>
> `src/`
>
> - **`api/`** - containing all api logic with model, services, controller and routes
> - **`bin/`** - server configuration folder
> - **`config/`** - this folder contains all the configs file (database, passport, etc...)
> - **`constants/`** - this folder contains all the global constants
> - **`logs/`** - the logger file will be stored here
> - **`helpers/`** - some helpers func i.e. an error helper that returns json everytime an error comes in
> - **`middlewares/`** - here you can find all the custom middlewares
> - **`services/`** - here we store all the services; i.e. here we define methods to manipulate a db model entity
> - **`tests/`** - here we store all the jest test
> - **`utils/`** - containing some utils function to be reused in the code (i.e. axios global configuration)
## Getting Started
Copy the .env.example to .env. Be sure to fill all the global variables. Alternatively you can use the script `generate-env.sh` in the scripts folder. This script will generate a `.env.test.local` and you can copy this file to .env
```bash
cp env.example .env
```
Then replace:
1. `MONGO_URI` string with your Mongo connection
1. `MONGO_URI_TEST` string with your Mongo Test connection
2. `MYSQL_HOST_STAGE` string with your mysql host name
- `MYSQL_USER_STAGE` string with your mysql username
- `MYSQL_PASSWORD_STAGE` string with your mysql password name
- `MYSQL_DB_STAGE` string with your mysql db name
- `MYSQL_SOCKET_STAGE` string with your mysql socket name
3. `GOOGLE_APPLICATION_CREDENTIALS` path with yours
4. `GOOGLE_PROJECT_ID` with yours
5. `SENDGRID_API_KEY` with yours
6. `SENDGRID_SENDER_EMAIL` with yours
In order to Google Cloud Storage works follow instructions located in `./src/config/gcloud/README.md`
---
To get started with this repo npm install in the root folder
```bash
npm install
```
To getting started with a dev environment. Here we use nodemon and babel-node to restart the server asa we change
something
```bash
npm run start:dev
```
To compile the code and create a production build
```bash
npm run compile
```
This command will create a build in the root directory
To start with a production ready build you can run this command
```bash
# This set the NODE_ENV to production, npm-run-all, create a build and run the server command
npm run start
```
If you have a build and you want to node the build you can run
```bash
# This command launch the node instance inside the ./build/bin/server
npm run server
```
## Docker Ready
### Here we use the multistage build to optimize speed and size of the final image
If you use Docker and wanna dockerize the app you can run the command
```bash
docker build -t <dockerhubusername>/<docker-image-name>:<tag> .
```
then
```bash
docker run --name <docker-process-name> -d - p 3000:3000 <dockerhubusername>/<docker-image-name>:<tag>
```

54
infra/README.md Normal file
View File

@ -0,0 +1,54 @@
#INFRASTRUCTURE FOLDER
This folder should be moved to the root folder where all the services are located.
Replace all the key-value pairs with yours
- `k8s` folder contains all production kubernetes manifests
- `k8s-dev` folder contains all development kubernetes manifests to run with skaffold
- `scripts` folder contains all script related to the creation of a cluster or running skaffold or secret creation
## Skaffold File
For production environment: `./k8s/skaffold.yaml`
For development environment: `./k8s-dev/skaffold.yaml`
Remember to put this file in the root of multi-services project. Depending on the environment, you should specify the
correct skaffold configuration.
- If you use Docker, you should install NGINX at this link
[NGINX x Docker](https://kubernetes.github.io/ingress-nginx/deploy/)
## TASK TO MAKE THIS WORK
1. Create a project in GCP
2. Go to `./scripts/gke-autopilot.sh` and change the <google-cloud-project-id> with your project id.
3. Launch the script with `chmod +x gke-autopilot.sh && ./gke-autopilot.sh`
4. Just in case context is not changed, you should change with `kubectl config use-context <clustern-name>`
5. Put the file `skaffold.yaml` in your root folder where all the services are located.
6. For each YAML file change the `project-id`, `servicename` and all other env variables with your
7. After you changed all the configuration files you can launch skaffold command with `skaffold run`
## USEFUL COMANDS
- Change the context of kubernetes
```bash
kubectl config use-context <clustern-name>
```
- Build the container in gcloud with the command. In the root where Dockerfile is located
```bash
gcloud builds submit --tag gcr.io/<gcp-project-id>/<image-name> .
```
- CREATE SECRET FROM JSON FILE
- google-application-credentials = the name of the secret to be stored
- google-application-credentials.json = the file name and the file will be stored in a volume
- ./google-application-credentials.json = the actual file downloaded and that is in the config folder
```bash
kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json
```

View File

@ -0,0 +1,229 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: servicename-depl
namespace: default
labels:
app: servicename
spec:
replicas: 1
selector:
matchLabels:
app: servicename
template:
metadata:
labels:
app: servicename
spec:
volumes:
- name: google-cloud-keys
secret:
secretName: google-application-credentials
- name: proxy-to-another-gcp-project # name of the volumes that contain the proxy to another gcp project
secret:
secretName: proxy-to-another-gcp-project-secret
containers:
- name: servicename
#Local Configuration
image: org_name/project_name/servicename:latest
volumeMounts:
- name: google-cloud-keys
mountPath: /var/secrets/google
env:
#SERVICE CONFIGURATION
- name: HOST
value: '0.0.0.0'
- name: SERVICE_NAME
value: 'your-service-name'
- name: PORT
value: '3000'
- name: HASH
value: '10'
#JWT CONFIGURATION
- name: JWT_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: JWT_KEY
- name: SECRET
valueFrom:
secretKeyRef:
name: shared-secrets
key: SECRET
#MONGO CONFIGURATION
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: shared-secrets
key: MONGO_URI_TEST # We use the test one also in mongouri. this happen cause when launch skaffold in local it has node_env production
- name: MONGO_URI_TEST
valueFrom:
secretKeyRef:
name: shared-secrets
key: MONGO_URI_TEST
#GOOGLE CLOUD CONFIGURATION
- name: GOOGLE_APPLICATION_CREDENTIALS
value: '/var/secrets/google/google-application-credentials.json'
- name: GOOGLE_PROJECT_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_PROJECT_ID
- name: GOOGLE_CLOUD_PROJECT
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLOUD_PROJECT
- name: GOOGLE_STORAGE_BUCKET_NAME
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_STORAGE_BUCKET_NAME
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLIENT_ID
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLIENT_SECRET
- name: GOOGLE_MAPS_API_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_MAPS_API_KEY
#SPARKPOST CONFIGURATION
- name: SPARKPOST_API_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: SPARKPOST_API_KEY
- name: SPARKPOST_SENDER_DOMAIN
valueFrom:
secretKeyRef:
name: shared-secrets
key: SPARKPOST_SENDER_DOMAIN
#MESSAGEBIRD CONFIGURATION
- name: MESSAGEBIRD_ACCESS_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_ACCESS_KEY
- name: MESSAGEBIRD_WHATSAPP_CHANNEL_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_WHATSAPP_CHANNEL_ID
- name: MESSAGEBIRD_TEMPLATE_NAMESPACE_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_TEMPLATE_NAMESPACE_ID
- name: MESSAGEBIRD_TEMPLATE_NAME_TEST
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_TEMPLATE_NAME_TEST
#MYSQL CONFIGURATION
- name: MYSQL_HOST_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_HOST_STAGE
- name: MYSQL_USER_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_USER_STAGE
- name: MYSQL_PASSWORD_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_PASSWORD_STAGE
- name: MYSQL_DB_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_DB_STAGE
- name: MYSQL_SOCKET_STAGE
value: '/cloudsql/your-socket-name'
- name: MYSQL_HOST_PROD
value: '127.0.0.1' #we use localhost because we mounted a cloud proxy sql
- name: MYSQL_USER_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_USER_PROD
- name: MYSQL_PASSWORD_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_PASSWORD_PROD
- name: MYSQL_DB_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_DB_PROD
- name: MYSQL_SOCKET_PROD
value: '/cloudsql/your-cloudsql-socket'
- name: cloud-sql-proxy
# It is recommended to use the latest version of the Cloud SQL proxy
# Make sure to update on a regular schedule!
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.0
args:
# If connecting from a VPC-native GKE cluster, you can use the
# following flag to have the proxy connect over private IP
# - "--private-ip"
# Enable structured logging with LogEntry format:
- "--structured-logs"
# Defaults: MySQL: 3306, Postgres: 5432, SQLServer: 1433
# Replace DB_PORT with the port the proxy should listen on
- "--port=3306"
- "cloud-sql-instances=instance-name"
# [START cloud_sql_proxy_k8s_volume_mount]
# This flag specifies where the service account key can be found
- '--credentials-file=/var/secrets/google/proxy-to-another-gcp-project.json'
securityContext:
# The default Cloud SQL proxy image runs as the
# "nonroot" user and group (uid: 65532) by default.
runAsNonRoot: true
volumeMounts:
- name: proxy-to-another-gcp-project
mountPath: /var/secrets/google
readOnly: true
# [END cloud_sql_proxy_k8s_volume_mount]
# Resource configuration depends on an application's requirements. You
# should adjust the following values based on what your application
# needs. For details, see https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
requests:
# The proxy's memory use scales linearly with the number of active
# connections. Fewer open connections will use less memory. Adjust
# this value based on your application's requirements.
memory: '2Gi'
# The proxy's CPU use scales linearly with the amount of IO between
# the database and the application. Adjust this value based on your
# application's requirements.
cpu: '1'
---
apiVersion: v1
kind: Service
metadata:
name: servicename-srv
spec:
type: ClusterIP
selector:
app: servicename
ports:
- name: servicename
protocol: TCP
port: 3000
targetPort: 3000

View File

@ -0,0 +1,42 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-service
annotations:
#Local configuration - Remember to install nginx
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/use-regex: 'true'
nginx.ingress.kubernetes.io/enable-cors: 'true'
nginx.ingress.kubernetes.io/cors-allow-methods: 'GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS'
nginx.ingress.kubernetes.io/cors-allow-origin: 'http://localhost:3000'
nginx.ingress.kubernetes.io/cors-allow-credentials: 'true'
nginx.ingress.kubernetes.io/proxy-body-size: 8m
spec:
rules:
- host: testrestapi.eu.ngrok.io
http:
paths:
# Client implementation of React or a frontend client in general that doesn't have api versioning
- path: /?(.*)
pathType: ImplementationSpecific
backend:
service:
name: clientservicename-srv
port:
number: 3000
- path: /api/v1/service-1-name/?(.*)
pathType: ImplementationSpecific
backend:
service:
name: servicename-srv
port:
number: 3000
- path: /api/v1/service-2-name/?(.*)
pathType: ImplementationSpecific
backend:
service:
name: servicename2-srv
port:
number: 3000

View File

@ -0,0 +1,22 @@
apiVersion: v1
kind: Secret
metadata:
name: google-application-credentials #name of the secret to be mounted
type: Opaque
stringData: #file name that will be created to mount
google-application-credentials.json: |
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "your-private-key",
"client_email": "service-account-email",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "client_x509_cert_url"
}
#The same result can be achieved by using this kubectl command in the folder where google-application-credentials.json is
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

View File

@ -0,0 +1,22 @@
apiVersion: v1
kind: Secret
metadata:
name: proxy-to-another-gcp-project-secret #name of the secret to be mounted
type: Opaque
stringData: #file name that will be created to mount
proxy-to-another-gcp-project.json: |
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "your-private-key",
"client_email": "service-account-email",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "client_x509_cert_url"
}
#The same result can be achieved by using this kubectl command in the folder where google-application-credentials.json is
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

View File

@ -0,0 +1,49 @@
apiVersion: v1
kind: Secret
metadata:
name: shared-secrets
data:
#JWT CONFIGURATION
JWT_KEY: <base64-base64-value>
SECRET: <base64-base64-value>
#MONGODB CONFIGURATION:
MONGO_URI: <base64-value>
MONGO_URI_TEST: <base64-value>
#GOOGLE CLOUD CONFIGURATION
GOOGLE_PROJECT_ID: <base64-value>
GOOGLE_CLOUD_PROJECT: <base64-value>
GOOGLE_STORAGE_BUCKET_NAME: <base64-value>
GOOGLE_CLIENT_ID: <base64-value>
GOOGLE_CLIENT_SECRET: <base64-value>
GOOGLE_MAPS_API_KEY: <base64-value>
#SPARKPOST CONFIGURATION
SPARKPOST_API_KEY: <base64-value> #Use test key here
SPARKPOST_SENDER_DOMAIN: <base64-value>
# MESSAGEBIRD CONFIGURATION
MESSAGEBIRD_ACCESS_KEY: <base64-value> #Use test key here
MESSAGEBIRD_WHATSAPP_CHANNEL_ID: <base64-value>
MESSAGEBIRD_TEMPLATE_NAMESPACE_ID: <base64-value>
MESSAGEBIRD_TEMPLATE_NAME_TEST: <base64-value>
#MYSQL CONFIGURATION SECRECTS
MYSQL_HOST_STAGE: <base64-value>
MYSQL_USER_STAGE: <base64-value>
MYSQL_PASSWORD_STAGE: <base64-value>
MYSQL_DB_STAGE: <base64-value>
MYSQL_SOCKET_STAGE: <base64-value> #not necessary
MYSQL_HOST_PROD: <base64-value>
MYSQL_USER_PROD: <base64-value>
MYSQL_PASSWORD_PROD: <base64-value>
MYSQL_DB_PROD: <base64-value>
MYSQL_SOCKET_PROD: <base64-value> #not necessary
#kubectl create secret generic jwt-secret --from-literal=JWT_KEY=JWT_SECRET
#Don't forget to create the google-application-credentials secret with
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

View File

@ -0,0 +1,47 @@
apiVersion: skaffold/v4beta1
kind: Config
metadata:
name: project-id #project id
build:
artifacts:
#Local configuration
# Client context of React
- image: org_name/project_name/client-servicename
context: client-service-folder
sync:
manual:
- src: ./src/**/*.ts
dest: .
- src: "***/*.html"
dest: .
- src: "***/*.css"
dest: .
docker:
dockerfile: Dockerfile
# Service 1 context
- image: org_name/project_name/servicename
context: service-folder #folder where codebase is stored
sync:
manual:
- src: src/**/*.ts
dest: .
docker:
dockerfile: Dockerfile
- image: org_name/project_name/servicename2
context: service2-folder
sync:
manual:
- src: src/**/*.ts
dest: .
docker:
dockerfile: Dockerfile
tagPolicy:
sha256: {} #this tag policy uses the tag latest of image
#Local configuration
local:
push: false
manifests:
rawYaml:
- ./infra/k8s-dev/*
deploy:
kubectl: {}

View File

@ -0,0 +1,8 @@
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
name: project-id-certificate
spec:
domains:
- domainname.com
- api.domainname.com

View File

@ -0,0 +1,262 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: servicename-depl
namespace: default
labels:
app: servicename
spec:
replicas: 1
selector:
matchLabels:
app: servicename
template:
metadata:
labels:
app: servicename
spec:
volumes:
- name: google-cloud-keys
secret:
secretName: google-application-credentials
- name: proxy-to-another-gcp-project # name of the volumes that contain the proxy to another gcp project
secret:
secretName: proxy-to-another-gcp-project-secret
containers:
- name: servicename
#Cloud Configuration
image: europe-west1-docker.pkg.dev/your-artifact-repository/servicename:latest
imagePullPolicy: Always
# Liveness Probe Configuration
livenessProbe:
failureThreshold: 3
httpGet:
path: /api/v1/servicename/
port: 3000
scheme: HTTP
initialDelaySeconds: 60
periodSeconds: 60
successThreshold: 1
timeoutSeconds: 10
# Readiness Probe Configuration
readinessProbe:
failureThreshold: 3
httpGet:
path: /api/v1/servicename/
port: 3000
scheme: HTTP
initialDelaySeconds: 60
periodSeconds: 60
successThreshold: 1
timeoutSeconds: 10
volumeMounts:
- name: google-cloud-keys
mountPath: /var/secrets/google
ports:
- containerPort: 3000
env:
#SERVICE CONFIGURATION
- name: HOST
value: '0.0.0.0'
- name: SERVICE_NAME
value: 'your_service_name'
- name: PORT
value: '3000'
- name: HASH
value: '10'
#JWT CONFIGURATION
- name: JWT_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: JWT_KEY
- name: SECRET
valueFrom:
secretKeyRef:
name: shared-secrets
key: SECRET
#MONGO CONFIGURATION
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: shared-secrets
key: MONGO_URI # We use the test one also in mongouri. this happen cause when launch skaffold in local it has node_env production
- name: MONGO_URI_TEST
valueFrom:
secretKeyRef:
name: shared-secrets
key: MONGO_URI_TEST
#GOOGLE CLOUD CONFIGURATION
- name: GOOGLE_APPLICATION_CREDENTIALS
value: '/var/secrets/google/google-application-credentials.json'
- name: GOOGLE_PROJECT_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_PROJECT_ID
- name: GOOGLE_CLOUD_PROJECT
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLOUD_PROJECT
- name: GOOGLE_STORAGE_BUCKET_NAME
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_STORAGE_BUCKET_NAME
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLIENT_ID
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_CLIENT_SECRET
- name: GOOGLE_MAPS_API_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: GOOGLE_MAPS_API_KEY
#SPARKPOST CONFIGURATION
- name: SPARKPOST_API_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: SPARKPOST_API_KEY
- name: SPARKPOST_SENDER_DOMAIN
valueFrom:
secretKeyRef:
name: shared-secrets
key: SPARKPOST_SENDER_DOMAIN
#MESSAGEBIRD CONFIGURATION
- name: MESSAGEBIRD_ACCESS_KEY
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_ACCESS_KEY
- name: MESSAGEBIRD_WHATSAPP_CHANNEL_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_WHATSAPP_CHANNEL_ID
- name: MESSAGEBIRD_TEMPLATE_NAMESPACE_ID
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_TEMPLATE_NAMESPACE_ID
- name: MESSAGEBIRD_TEMPLATE_NAME_TEST
valueFrom:
secretKeyRef:
name: shared-secrets
key: MESSAGEBIRD_TEMPLATE_NAME_TEST
#MYSQL CONFIGURATION
- name: MYSQL_HOST_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_HOST_STAGE
- name: MYSQL_USER_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_USER_STAGE
- name: MYSQL_PASSWORD_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_PASSWORD_STAGE
- name: MYSQL_DB_STAGE
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_DB_STAGE
- name: MYSQL_SOCKET_STAGE
value: '/cloudsql/your-socket-name'
- name: MYSQL_HOST_PROD
value: '127.0.0.1' #we use localhost because we mounted a cloud proxy sql
- name: MYSQL_USER_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_USER_PROD
- name: MYSQL_PASSWORD_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_PASSWORD_PROD
- name: MYSQL_DB_PROD
valueFrom:
secretKeyRef:
name: shared-secrets
key: MYSQL_DB_PROD
- name: MYSQL_SOCKET_PROD
value: '/cloudsql/your-socket-name'
- name: cloud-sql-proxy
# It is recommended to use the latest version of the Cloud SQL proxy
# Make sure to update on a regular schedule!
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.1.0
args:
# If connecting from a VPC-native GKE cluster, you can use the
# following flag to have the proxy connect over private IP
# - "--private-ip"
# Enable structured logging with LogEntry format:
- '--structured-logs'
# Defaults: MySQL: 3306, Postgres: 5432, SQLServer: 1433
# Replace DB_PORT with the port the proxy should listen on
- '--port=3306'
- 'cloud-sql-instances=instance-name'
# [START cloud_sql_proxy_k8s_volume_mount]
# This flag specifies where the service account key can be found
- '--credentials-file=/var/secrets/google/proxy-to-another-gcp-project.json'
securityContext:
# The default Cloud SQL proxy image runs as the
# "nonroot" user and group (uid: 65532) by default.
runAsNonRoot: true
volumeMounts:
- name: proxy-to-another-gcp-project
mountPath: /var/secrets/google
readOnly: true
# [END cloud_sql_proxy_k8s_volume_mount]
# Resource configuration depends on an application's requirements. You
# should adjust the following values based on what your application
# needs. For details, see https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
requests:
# The proxy's memory use scales linearly with the number of active
# connections. Fewer open connections will use less memory. Adjust
# this value based on your application's requirements.
memory: '2Gi'
# The proxy's CPU use scales linearly with the amount of IO between
# the database and the application. Adjust this value based on your
# application's requirements.
cpu: '1'
---
apiVersion: v1
kind: Service
metadata:
name: servicename-srv
spec:
# type: ClusterIP
type: NodePort
selector:
app: servicename
ports:
- name: servicename
protocol: TCP
port: 3000
targetPort: 3000

View File

@ -0,0 +1,37 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-service
annotations:
#Cloud Configuration
kubernetes.io/ingress.class: gce
kubernetes.io/ingress.global-static-ip-name: project-id-static-ip
networking.gke.io/managed-certificates: project-id-certificate
spec:
rules:
- host: domainname.com
http:
paths:
- path: /*
pathType: ImplementationSpecific
backend:
service:
name: clientservicename-srv
port:
number: 3000
- path: /api/v1/servicename/*
pathType: ImplementationSpecific
backend:
service:
name: servicename-srv
port:
number: 3000
- path: /api/v1/servicename2/*
pathType: ImplementationSpecific
backend:
service:
name: servicename2-srv
port:
number: 3000

View File

@ -0,0 +1,22 @@
apiVersion: v1
kind: Secret
metadata:
name: google-application-credentials #name of the secret to be mounted
type: Opaque
stringData: #file name that will be created to mount
google-application-credentials.json: |
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "your-private-key",
"client_email": "service-account-email",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "client_x509_cert_url"
}
#The same result can be achieved by using this kubectl command in the folder where google-application-credentials.json is
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

View File

@ -0,0 +1,22 @@
apiVersion: v1
kind: Secret
metadata:
name: proxy-to-another-gcp-project-secret #name of the secret to be mounted
type: Opaque
stringData: #file name that will be created to mount
proxy-to-another-gcp-project.json: |
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "your-private-key",
"client_email": "service-account-email",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "client_x509_cert_url"
}
#The same result can be achieved by using this kubectl command in the folder where google-application-credentials.json is
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

View File

@ -0,0 +1,49 @@
apiVersion: v1
kind: Secret
metadata:
name: shared-secrets
data:
#JWT CONFIGURATION
JWT_KEY: <base64-base64-value>
SECRET: <base64-base64-value>
#MONGODB CONFIGURATION:
MONGO_URI: <base64-value>
MONGO_URI_TEST: <base64-value>
#GOOGLE CLOUD CONFIGURATION
GOOGLE_PROJECT_ID: <base64-value>
GOOGLE_CLOUD_PROJECT: <base64-value>
GOOGLE_STORAGE_BUCKET_NAME: <base64-value>
GOOGLE_CLIENT_ID: <base64-value>
GOOGLE_CLIENT_SECRET: <base64-value>
GOOGLE_MAPS_API_KEY: <base64-value>
#SPARKPOST CONFIGURATION
SPARKPOST_API_KEY: <base64-value> #Use test key here
SPARKPOST_SENDER_DOMAIN: <base64-value>
# MESSAGEBIRD CONFIGURATION
MESSAGEBIRD_ACCESS_KEY: <base64-value> #Use test key here
MESSAGEBIRD_WHATSAPP_CHANNEL_ID: <base64-value>
MESSAGEBIRD_TEMPLATE_NAMESPACE_ID: <base64-value>
MESSAGEBIRD_TEMPLATE_NAME_TEST: <base64-value>
#MYSQL CONFIGURATION SECRECTS
MYSQL_HOST_STAGE: <base64-value>
MYSQL_USER_STAGE: <base64-value>
MYSQL_PASSWORD_STAGE: <base64-value>
MYSQL_DB_STAGE: <base64-value>
MYSQL_SOCKET_STAGE: <base64-value> #not necessary
MYSQL_HOST_PROD: <base64-value>
MYSQL_USER_PROD: <base64-value>
MYSQL_PASSWORD_PROD: <base64-value>
MYSQL_DB_PROD: <base64-value>
MYSQL_SOCKET_PROD: <base64-value> #not necessary
#kubectl create secret generic jwt-secret --from-literal=JWT_KEY=JWT_SECRET
#Don't forget to create the google-application-credentials secret with
#kubectl create secret generic google-application-credentials --from-file=google-application-credentials.json=./google-application-credentials.json

37
infra/k8s/skaffold.yaml Normal file
View File

@ -0,0 +1,37 @@
# SKAFFOLD CONFIGURATION FOR PRODUCTION
apiVersion: skaffold/v4beta1
kind: Config
metadata:
name: project-id #project id
build:
artifacts:
# Client context of React
- image: europe-west1-docker.pkg.dev/your_artifact_url/client-servicename
context: client-service
sync:
manual:
- src: ./src/**/*.ts
dest: .
- src: "***/*.html"
dest: .
- src: "***/*.css"
dest: .
docker:
dockerfile: Dockerfile
- image: europe-west1-docker.pkg.dev/your_artifact_url/servicename
context: service-folder
sync:
manual:
- src: ./src/**/*.ts
dest: .
docker:
dockerfile: Dockerfile
tagPolicy:
sha256: {}
googleCloudBuild:
projectId: your-google-cloud-project-id
manifests:
rawYaml:
- ./infra/k8s/*
deploy:
kubectl: {}

36
infra/terraform/.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Local .terraform directories
**/.terraform/*
.terraform/
# .tfstate files
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude all .tfvars files, which are likely to contain sensitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
*.tfvars
*.tfvars.json
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Include override files you do wish to add to version control using negated pattern
# !example_override.tf
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
# Ignore CLI configuration files
.terraformrc
terraform.rc

View File

@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/google" {
version = "4.55.0"
constraints = "4.55.0"
hashes = [
"h1:GMfPJSl9+PS3tHmHmUMo/4CkJ9/tHvZwV2aVp050Fcc=",
"zh:0a82a76dc4bbe05418075f88830f73ad3ca9d56d83a172faaf3306b016219d52",
"zh:367e3c0ce96ab8f9ec3e1fab5a4f9a48b3b5b336622b36b828f75bf6fb663001",
"zh:51fd41c7508c4c39830e5c2885bc053e90d5d24fc90462235b69394185b7fa1d",
"zh:7ebe62261c522631d22ab06951d0d6a1bf629b98aea5d9fe2e2e50ca256cf395",
"zh:9dd119eca735471d61fe9e4cc45e8c257275e2e9f4da30fba7296fc7ae8de99e",
"zh:a4426a0d24dcf8b3899e17530fabb3fb5791ff7db65404c26e66b031a8422bd2",
"zh:c1e93a786b6d014610c3f83fda12b3044009947f729b2042635fa66d9f387c47",
"zh:ea0703ee2f5e3732077e946cfa5cdd85119ef4ecc898a2affdeef9de9f92fe4e",
"zh:ecada51dd406f46e9fce7dafb0b8ef3a671b8d572dbc1d39d9fdc137029f5275",
"zh:effb91791080a86ff130b517bce5253aed1372ad2c6f9cfb252375a196b9f730",
"zh:f1885b811a31e37d53bd780d2485c19754ee2db0a66affeb5e788aa9b1950b8c",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}

24
infra/terraform/README.md Normal file
View File

@ -0,0 +1,24 @@
REMEMBER TO ADD A FILE CALLED `terraform.tfvars` with the following options:
with the following options
```
region = "europe-west1"
zone = "europe-west1-b"
location = "EU"
project = "development-test-skeldon"
environment = "prod"
app_name = "test-rest-api-app"
```
Then run terraform commands as usual.
## Terraform commands could be:
```bash
terraform init # only the first time
terraform fmt # to format the code
terraform validate # to validate the code
terraform plan # to see what will be created
terraform apply # to create the infrastructure
```

View File

@ -0,0 +1,20 @@
resource "google_container_cluster" "app_cluster" {
name = "${var.app_name}-cluster-${var.environment}"
location = var.region
ip_allocation_policy {
}
enable_autopilot = true
}
resource "google_compute_global_address" "external_static_ip" {
name = "${var.app_name}-ingress-static-ip"
address_type = "EXTERNAL"
ip_version = "IPV4"
project = var.project
description = "External static IP address for app"
}
output "external_static_ip" {
value = google_compute_global_address.external_static_ip.address
description = "External static IP address for app"
}

14
infra/terraform/main.tf Normal file
View File

@ -0,0 +1,14 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "4.55.0"
}
}
}
provider "google" {
project = var.project
region = var.region
zone = var.zone
}

View File

@ -0,0 +1,12 @@
resource "google_artifact_registry_repository" "repo" {
location = "europe-west1"
repository_id = "${var.app_name}-artifact-repository"
description = "Artifact repository created by Terraform"
format = "DOCKER"
}
output "artifact_registry_name" {
value = google_artifact_registry_repository.repo.name
description = "Artifact registry name"
}

View File

@ -0,0 +1,24 @@
resource "google_storage_bucket" "prod-bucket" {
name = "${var.app_name}-bucket-${var.environment}"
location = var.region
project = var.project
storage_class = "STANDARD"
uniform_bucket_level_access = false
# versioning {
# enabled = true
# }
# lifecycle_rule {
# action {
# type = "Delete"
# storage_class = "NEARLINE"
# }
# condition {
# age = 30
# }
# }
}
output "prod_bucket_name" {
value = google_storage_bucket.prod-bucket.name
description = "Prod Bucket name"
}

View File

@ -0,0 +1,6 @@
variable "region" {}
variable "zone" {}
variable "location" {}
variable "project" {}
variable "environment" {}
variable "app_name" {}

9
jest.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
clearMocks: true,
// roots: ['<rootDir>/src'],
collectCoverage: true,
collectCoverageFrom: ['src/**/([a-zA-Z_]*).{js,ts}', '!**/*.test.{js,ts}'],
};

23984
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

103
package.json Normal file
View File

@ -0,0 +1,103 @@
{
"name": "typescript-rest-api-backend",
"version": "1.0.0",
"description": "Express Typescript Rest API backend template with full TS support following gts style guide and gke integration",
"main": "src/index.ts",
"scripts": {
"start:dev": "cross-env NODE_ENV=development nodemon ./src/bin/server",
"start:prod": "npm run prod",
"prod": "cross-env NODE_ENV=production npm-run-all compile server",
"server": "node ./build/src/bin/server",
"lint": "gts lint",
"clean": "gts clean",
"compile": "npm run clean && tsc",
"watch": "tsc -w",
"fix": "gts fix",
"prepare": "npm run compile",
"pretest": "npm run compile",
"posttest": "npm run lint",
"test": "cross-env NODE_ENV=test jest --verbose",
"docs": "rm -rf docs/ && typedoc",
"generate:env": "sh ./scripts/generate-env.sh"
},
"author": "Giuseppe Albrizio",
"license": "MIT",
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/compression": "^1.7.2",
"@types/cookie-parser": "^1.4.3",
"@types/cors": "^2.8.13",
"@types/debug": "^4.1.8",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"@types/jest": "^29.5.3",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash": "^4.14.195",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.1",
"@types/passport": "^1.0.12",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-local": "^1.0.35",
"@types/pdfmake": "^0.2.2",
"@types/sparkpost": "^2.1.5",
"@types/supertest": "^2.0.12",
"@types/swagger-ui-express": "^4.1.3",
"@types/validator": "^13.7.17",
"cross-env": "^7.0.3",
"gts": "^3.1.1",
"jest": "^29.6.1",
"npm-run-all": "^4.1.5",
"rimraf": "^5.0.1",
"supertest": "^6.3.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typedoc": "^0.24.8",
"typescript": "~5.1.6"
},
"dependencies": {
"@google-cloud/pubsub": "^3.7.1",
"@google-cloud/storage": "^6.11.0",
"@googlemaps/google-maps-services-js": "^3.3.33",
"@hokify/agenda": "^6.3.0",
"@types/morgan": "^1.9.4",
"axios": "^1.4.0",
"bcryptjs": "^2.4.3",
"clean-deep": "^3.4.0",
"compression": "^1.7.4",
"connect-mongo": "^5.0.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"crypto-random-string": "^5.0.0",
"debug": "^4.3.4",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^6.7.1",
"express-session": "^1.17.3",
"firebase-admin": "^11.9.0",
"helmet": "^7.0.0",
"http": "^0.0.1-security",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"messagebird": "^4.0.1",
"mongodb": "^5.7.0",
"mongoose": "^7.3.3",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"multer-cloud-storage": "^3.0.0",
"mysql2": "^3.5.1",
"nodemon": "^3.0.1",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"pdfmake": "^0.2.7",
"slugify": "^1.6.6",
"sparkpost": "^2.1.4",
"swagger-ui-express": "^5.0.0",
"validator": "^13.9.0",
"winston": "^3.10.0",
"xmlbuilder2": "^3.1.1",
"xss-clean": "^0.1.1"
}
}

16
public/index.html Normal file
View File

@ -0,0 +1,16 @@
<html>
<head>
<title>Express</title>
<link
rel="stylesheet"
type="text/css"
href="public/stylesheets/style.css"
/>
</head>
<body>
<h1>Express</h1>
<p>Welcome to Express Typescript Rest API</p>
<p>Got to http://localhost:3000/api/v1 to test your API endpoint</p>
</body>
</html>

View File

@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}

191
scripts/generate-env.sh Executable file
View File

@ -0,0 +1,191 @@
#!/bin/sh
# Define color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Check if .env file exists and delete
echo "${YELLOW}Deleting old .env file...${NC}"
rm -f ./.env.test.local
# Greet user
echo "${CYAN}Hello! Let's set up your environment variables.${NC}"
# Ask user for variable content
# Ask user for variable content and validate input
while true; do
read -p "What is the HOST? [0.0.0.0] " HOST
# HOST=${HOST:-0.0.0.0} # set default value for PORT
if [ -z "$HOST" ]; then
echo "${RED}HOST cannot be blank. Please enter a value.${NC}"
else
break
fi
done
# Ask user for variable content and validate input
while true; do
read -p "What is the port you want to run the server on? [3000] " PORT
# PORT=${PORT:-3000} # set default value for PORT
if [ -z "$PORT" ]; then
echo "${RED}PORT cannot be blank. Please enter a value.${NC}"
else
break
fi
done
while true; do
read -p "What is the name of the service? " SERVICE_NAME
if [ -z "$SERVICE_NAME" ]; then
echo "${RED}SERVICE_NAME cannot be blank. Please enter a value.${NC}"
else
break
fi
done
while true; do
read -p "What is your JWT_KEY? " JWT_KEY
if [ -z "$JWT_KEY" ]; then
echo "${RED}JWT_KEY cannot be blank. Please enter a value.${NC}"
else
break
fi
done
while true; do
read -p "What is your SECRET? " SECRET
if [ -z "$SECRET" ]; then
echo "${RED}SECRET cannot be blank. Please enter a value.${NC}"
else
break
fi
done
HASH=10 # set default value for HASH
read -p "What is the path to your Google Application Credentials file? [./src/config/gcloud/google-application-credentials.json] " GOOGLE_APPLICATION_CREDENTIALS
GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS:-./src/config/gcloud/google-application-credentials.json} # set default value for GOOGLE_APPLICATION_CREDENTIALS
read -p "What is your Google Cloud project ID? " GOOGLE_PROJECT_ID
read -p "What is your Google Cloud Storage bucket name? " GOOGLE_STORAGE_BUCKET_NAME
read -p "What is your Google Client ID? " GOOGLE_CLIENT_ID
read -p "What is your Google Client Secret? " GOOGLE_CLIENT_SECRET
read -p "What is your Google Maps API key? " GOOGLE_MAPS_API_KEY
read -p "What is your CLIENT_URL? [http://localhost:3000] " CLIENT_URL
CLIENT_URL=${CLIENT_URL:-http://localhost:3000} # set default value for CLIENT_URL
read -p "What is your MongoDB URI? [mongodb://localhost:27017/database_name] " MONGO_URI
MONGO_URI=${MONGO_URI:-mongodb://localhost:27017/database_name} # set default value for MONGO_URI
read -p "What is your MongoDB test URI? [mongodb://localhost:27017/test_database_name] " MONGO_URI_TEST
MONGO_URI_TEST=${MONGO_URI_TEST:-mongodb://localhost:27017/test_database_name} # set default value for MONGO_URI_TEST
read -p "What is your MongoDB username? " MONGO_USER
MONGO_USER=${MONGO_USER:-'your_mongo_user'} # set default value for MONGO_USER and add single quotes around the value
read -p "What is your MongoDB password? " MONGO_PASS
MONGO_PASS=${MONGO_PASS:-'your_mongo_password'} # set default value for MONGO_PASS and add single quotes around the value
read -p "What is your MySQL staging host? " MYSQL_HOST_STAGE
MYSQL_HOST_STAGE=${MYSQL_HOST_STAGE:-'your_myql_host_stage'} # set default value for MYSQL_HOST_STAGE and add single quotes around the value
read -p "What is your MySQL staging user? " MYSQL_USER_STAGE
MYSQL_USER_STAGE=${MYSQL_USER_STAGE:-'your_myql_user'} # set default value for MYSQL_USER_STAGE and add single quotes around the value
read -p "What is your MySQL staging password? " MYSQL_PASSWORD_STAGE
MYSQL_PASSWORD_STAGE=${MYSQL_PASSWORD_STAGE:-'your_myql_pass'} # set default value for MYSQL_PASSWORD_STAGE and add single quotes around the value
read -p "What is your MySQL staging database? " MYSQL_DB_STAGE
MYSQL_DB_STAGE=${MYSQL_DB_STAGE:-'your_myql_db_name'} # set default value for MYSQL_DB_STAGE and add single quotes around the value
read -p "What is your MySQL staging socket? " MYSQL_SOCKET_STAGE
MYSQL_SOCKET_STAGE=${MYSQL_SOCKET_STAGE:-'/your/socket-cloud-sql'} # set default value for MYSQL_SOCKET_STAGE and add single quotes around the value
read -p "What is your MySQL production host? " MYSQL_HOST_PROD
MYSQL_HOST_PROD=${MYSQL_HOST_PROD:-'your_myql_host_stage'} # set default value for MYSQL_HOST_PROD and
read -p "What is your MySQL production user? " MYSQL_USER_PROD
MYSQL_USER_PROD=${MYSQL_USER_PROD:-'your_myql_user'} # set default value for MYSQL_USER_PROD and add single quotes around the value
read -p "What is your MySQL production password? " MYSQL_PASSWORD_PROD
MYSQL_PASSWORD_PROD=${MYSQL_PASSWORD_PROD:-'your_myql_pass'} # set default value for MYSQL_PASSWORD_PROD and add single quotes around the value
read -p "What is your MySQL production database? " MYSQL_DB_PROD
MYSQL_DB_PROD=${MYSQL_DB_PROD:-'your_myql_db_name'} # set default value for MYSQL_DB_PROD and add single quotes around the value
read -p "What is your MySQL production socket? " MYSQL_SOCKET_PROD
MYSQL_SOCKET_PROD=${MYSQL_SOCKET_PROD:-'/your/socket-cloud-sql'} # set default value for MYSQL_SOCKET_PROD and add single quotes around the value
read -p "What is your SparkPost API key? " SPARKPOST_API_KEY
SPARKPOST_API_KEY=${SPARKPOST_API_KEY:-'your_sparkpost_api_key'} # set default value for SPARKPOST_API_KEY and add single quotes around the value
read -p "What is your SparkPost sender domain? " SPARKPOST_SENDER_DOMAIN
SPARKPOST_SENDER_DOMAIN=${SPARKPOST_SENDER_DOMAIN:-'your_sparkpost_sender_domain'} # set default value for SPARKPOST_SENDER_DOMAIN and add single quotes around the value
read -p "What is your MessageBird Access Key? " MESSAGEBIRD_ACCESS_KEY
MESSAGEBIRD_ACCESS_KEY=${MESSAGEBIRD_ACCESS_KEY:-'your_messagbird_access_key'} # set default value for MESSAGEBIRD_ACCESS_KEY and add single quotes around the value
read -p "What is your MessageBird WhatsApp Channel ID? " MESSAGEBIRD_WHATSAPP_CHANNEL_ID
MESSAGEBIRD_WHATSAPP_CHANNEL_ID=${MESSAGEBIRD_WHATSAPP_CHANNEL_ID:-'your_messagebird_whatsapp_channel_id'} # set default value for MESSAGEBIRD_WHATSAPP_CHANNEL_ID and add single quotes around the value
read -p "What is your MessageBird Template Namespace ID? " MESSAGEBIRD_TEMPLATE_NAMESPACE_ID
MESSAGEBIRD_TEMPLATE_NAMESPACE_ID=${MESSAGEBIRD_TEMPLATE_NAMESPACE_ID:-'your_messagebird_template_namespace_id'} # set default value for MESSAGEBIRD_TEMPLATE_NAMESPACE_ID and add single quotes around the value
# Write variables to .env file one level up from the script's location
echo "# SERVER CONFIGURATION" >> ./.env.test.local
echo "HOST=${HOST}" >> ./.env.test.local
echo "PORT=${PORT}" >> ./.env.test.local
echo "SERVICE_NAME='${SERVICE_NAME}'" >> ./.env.test.local
echo "# JWT CONFIGURATION" >> ./.env.test.local
echo "JWT_KEY='${JWT_KEY}'" >> ./.env.test.local
echo "SECRET='${SECRET}'" >> ./.env.test.local
echo "HASH=${HASH}" >> ./.env.test.local
echo "# MONGO DB CONFIGURATION" >> ./.env.test.local
echo "MONGO_URI='${MONGO_URI}'" >> ./.env.test.local
echo "MONGO_URI_TEST='${MONGO_URI_TEST}'" >> ./.env.test.local
echo "MONGO_USER='${MONGO_USER}'" >> ./.env.test.local
echo "MONGO_PASS='${MONGO_PASS}'" >> ./.env.test.local
echo "# GOOGLE CLOUD CONFIGURATION" >> ./.env.test.local
echo "GOOGLE_APPLICATION_CREDENTIALS='${GOOGLE_APPLICATION_CREDENTIALS}'" >> ./.env.test.local
echo "GOOGLE_PROJECT_ID='${GOOGLE_PROJECT_ID}'" >> ./.env.test.local
echo "GOOGLE_STORAGE_BUCKET_NAME='${GOOGLE_STORAGE_BUCKET_NAME}'" >> ./.env.test.local
echo "GOOGLE_CLIENT_ID='${GOOGLE_CLIENT_ID}'" >> ./.env.test.local
echo "GOOGLE_CLIENT_SECRET='${GOOGLE_CLIENT_SECRET}'" >> ./.env.test.local
echo "GOOGLE_MAPS_API_KEY='${GOOGLE_MAPS_API_KEY}'" >> ./.env.test.local
echo "# CLIENT CONFIGURATION" >> ./.env.test.local
echo "CLIENT_URL='${CLIENT_URL}'" >> ./.env.test.local
echo "# MYSQL CONFIGURATION DEVELOPMENT" >> ./.env.test.local
echo "MYSQL_HOST_STAGE='${MYSQL_HOST_STAGE}'" >> ./.env.test.local
echo "MYSQL_USER_STAGE='${MYSQL_USER_STAGE}'" >> ./.env.test.local
echo "MYSQL_PASSWORD_STAGE='${MYSQL_PASSWORD_STAGE}'" >> ./.env.test.local
echo "MYSQL_DB_STAGE='${MYSQL_DB_STAGE}'" >> ./.env.test.local
echo "MYSQL_SOCKET_STAGE='${MYSQL_SOCKET_STAGE}'" >> ./.env.test.local
echo "# MYSQL CONFIGURATION PRODUCTION" >> ./.env.test.local
echo "MYSQL_HOST_PROD='${MYSQL_HOST_PROD}'" >> ./.env.test.local
echo "MYSQL_USER_PROD='${MYSQL_USER_PROD}'" >> ./.env.test.local
echo "MYSQL_PASSWORD_PROD='${MYSQL_PASSWORD_PROD}'" >> ./.env.test.local
echo "MYSQL_DB_PROD='${MYSQL_DB_PROD}'" >> ./.env.test.local
echo "MYSQL_SOCKET_PROD='${MYSQL_SOCKET_PROD}'" >> ./.env.test.local
echo "# SPARKPOST CONFIGURATION" >> ./.env.test.local
echo "SPARKPOST_API_KEY='${SPARKPOST_API_KEY}'" >> ./.env.test.local
echo "SPARKPOST_SENDER_DOMAIN='${SPARKPOST_SENDER_DOMAIN}'" >> ./.env.test.local
echo "# MESSAGEBIRD CONFIGURATION" >> ./.env.test.local
echo "MESSAGEBIRD_ACCESS_KEY='${MESSAGEBIRD_ACCESS_KEY}'" >> ./.env.test.local
echo "MESSAGEBIRD_WHATSAPP_CHANNEL_ID='${MESSAGEBIRD_WHATSAPP_CHANNEL_ID}'" >> ./.env.test.local
echo "MESSAGEBIRD_TEMPLATE_NAMESPACE_ID='${MESSAGEBIRD_TEMPLATE_NAMESPACE_ID}'" >> ./.env.test.local
# Success message
echo "${GREEN}Your environment variables have been written to ./.env.test.local. Thank you for using this script!${NC}"
echo "${GREEN}Please make sure to copy the .evn.test.local file to .env before going to production.${NC}"

26
scripts/skaffold-dev.sh Normal file
View File

@ -0,0 +1,26 @@
#!/bin/bash
Check kubectl context
CURRENT_CONTEXT=$(kubectl config current-context)
if [ "$CURRENT_CONTEXT" != "docker-desktop" ]; then
echo "Please set kubectl context to docker-desktop before running this script."
exit 1
fi
Set the options
NO_PRUNE=false
CACHE_ARTIFACTS=false
Parse the options
while getopts ":npca" opt; do
case $opt in
n) NO_PRUNE=true ;;
p) NO_PRUNE=false ;;
c) CACHE_ARTIFACTS=true ;;
a) CACHE_ARTIFACTS=false ;;
?) echo "Invalid option: -$OPTARG" >&2 ;;
esac
done
Run the skaffold dev command with the options
skaffold dev --no-prune=$NO_PRUNE --cache-artifacts=$CACHE_ARTIFACTS

View File

@ -0,0 +1,26 @@
/**
* Roles are used to define the access rights of a user.
* we use a custom middleware to check if the user has the right to access a route.
* the middleware is located in the middleware folder (verifyApiRights.middleware.ts)
*/
export interface IApiRoles {
superAdmin: string[];
admin: string[];
employee: string[];
client: string[];
vendor: string[];
user: string[];
}
const roles: IApiRoles = {
superAdmin: ['*', 'getUsers', 'createUsers', 'manageUsers', ' deleteUsers'],
admin: ['getUsers', 'createUsers', 'manageUsers', ' deleteUsers'],
employee: ['getUsers'],
client: ['getUsers'],
vendor: ['getUsers'],
user: ['getUsers'],
};
export const apiRoles = Object.keys(roles);
export const apiRolesRights = new Map(Object.entries(roles));

62
src/api/v1/app/README.md Normal file
View File

@ -0,0 +1,62 @@
# API App Readme
This is a sample controller and route for an Express app created for testing purposes.
## Getting Started
### Prerequisites
- Node.js installed on your local machine
- An understanding of the basics of Node.js and Express
### Installing
1. Clone the repository
2. Install dependencies by running `npm install`
3. Start the server by running `npm start`
4. Access the app at `http://localhost:3000/api/v1` with the following routes
## Usage
The app has the following endpoints:
- `/test-route-protection`: A protected route to check if the user is authenticated
- `/test-check-authenticated-user`: A route to check the authenticated user
- `/test-pubsub-publish`: A route to publish a message to a Google PubSub topic
- `/test-pubsub-pull-subscription`: A route to receive a message from a Google PubSub pull subscription
- `/test-pubsub-push-subscription`: A route to receive a message from a Google PubSub push subscription
- `/test-pdf-make`: A route to generate a PDF
To use the endpoints, send a request to the respective endpoint using a tool like Postman.
## Controller Functions
The app has the following controller functions:
### `checkRouteProtection`
A function to check if the user is authenticated and the test is completed.
### `checkUserLogged`
A function to check the authenticated user.
### `checkPubSubPublish`
A function to publish a message to a Google PubSub topic.
### `checkPubSubPullSubscription`
A function to receive a message from a Google PubSub pull subscription.
### `checkPubsubPushSubscription`
A function to receive a message from a Google PubSub push subscription.
### `checkPDFMake`
A function to generate a PDF.
## Acknowledgments
This app was created for testing purposes only.

View File

@ -0,0 +1,325 @@
import {Response} from 'express';
import {CustomError} from '../../../errors/CustomError.error';
import {ICustomExpressRequest} from '../../../middlewares/currentUser.middleware';
import Logger from '../../../lib/logger';
import {
publishMessageToPubSubTopic,
listenForPubSubPullSubscription,
PubSubPublishError,
} from '../../../services/google-pub-sub/pubsub.service';
import {generatePDF, IPDFObject} from '../../../services/pdf/pdf.service';
import {generateXML, IXMLObject} from '../../../services/xml/xml.service';
import {
IFirebaseMessage,
sendMulticastFirebaseMessage,
sendSingleFirebaseMessage,
} from '../../../services/messaging/firebase.service';
/**
* Test controller - Protected router test
* @param req - Custom request object
* @param res - Response object
*/
const checkRouteProtection = (
req: ICustomExpressRequest,
res: Response
): void => {
res.status(200).json({
status: 'success',
data: {
message: 'Yes you are authenticated and the test is completed',
},
});
};
/**
* Test controller - Check authenticated user
* @param req
* @param res
*/
const checkUserLogged = async (req: ICustomExpressRequest, res: Response) => {
try {
res.status(200).json({
status: 'success',
message: 'User logged retrieved',
userInPassport: req?.user,
userInSession: req?.session,
userInCustomMiddleware: req.currentUser,
});
} catch (error) {
Logger.debug(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
};
/**
* Test controller - Check PubSub publish message to a topic
* @param req
* @param res
*/
const checkPubSubPublish = async (
req: ICustomExpressRequest,
res: Response
) => {
try {
const message = await publishMessageToPubSubTopic(
{test: 'test', message: 'this is a message'},
'test'
);
res.status(200).json({
status: 'success',
message: 'Message published to PubSub',
response: {messageId: message},
});
} catch (error) {
if (error instanceof PubSubPublishError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: 'Failed to publish message to PubSub. Reason not known',
});
}
}
};
/**
* Test controller - Check PubSub message from a pull subscription
* @param req
* @param res
*/
const checkPubSubPullSubscription = async (
req: ICustomExpressRequest,
res: Response
) => {
try {
const response = await listenForPubSubPullSubscription(
'test-pull-subscription',
10
);
res.status(200).json({
status: 'success',
message: 'Message received from PubSub Pull Subscription',
response,
});
} catch (error) {
if (error instanceof PubSubPublishError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: 'Failed to listen for pull message. Reason not known',
});
}
}
};
/**
* Test controller - Check PubSub message from a push subscription
* @param req
* @param res
*/
const checkPubsubPushSubscription = async (
req: ICustomExpressRequest,
res: Response
) => {
try {
const data = Buffer.from(req.body.message.data, 'base64').toString('utf-8');
const response = await JSON.parse(data);
Logger.debug(response);
res.status(200).send('Message received from PubSub Push Subscription');
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: 'Failed to listen for push message. Reason not known',
});
}
}
};
/**
* Test controller - Check PDF generation
* @param req
* @param res
* @returns
*/
const checkPDFMake = async (req: ICustomExpressRequest, res: Response) => {
try {
const body: IPDFObject = {
key: 'value',
};
const directory = 'pdfs';
const response = await generatePDF(body, directory);
return res.status(200).json({
status: 'success',
message: 'PDF generated',
response,
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: `Failed to generate PDF. Reason error: ${error}`,
});
}
}
};
/**
* Test controller - Check XML generation
* @param req
* @param res
* @returns
*/
const checkXMLBuilder = async (req: ICustomExpressRequest, res: Response) => {
try {
const body: IXMLObject = {
key: 'value',
};
const response = await generateXML(body);
return res.status(200).json({
status: 'success',
message: 'XML generated',
response,
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: `Failed to generate PDF. Reason error: ${error}`,
});
}
}
};
/**
* Test controller - Check Firebase single notification
* @param req
* @param res
*/
const checkFirebaseSingleNotification = async (
req: ICustomExpressRequest,
res: Response
) => {
try {
const {message, userId} = req.body;
// validate that the message object has the correct interface
const validatedMessage: IFirebaseMessage = message;
const response = await sendSingleFirebaseMessage(validatedMessage, userId);
res.status(200).json({
status: 'success',
message: 'Message sent to Firebase',
response,
});
} catch (error) {
if (error instanceof CustomError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: 'Failed to send message to Firebase',
error,
});
}
}
};
/**
* Test controller - Check Firebase multicast notification
* @param req
* @param res
*/
const checkFirebaseMulticastNotification = async (
req: ICustomExpressRequest,
res: Response
) => {
try {
const {message, usersId} = req.body;
// validate that the message object has the correct interface
const validatedMessage: IFirebaseMessage = message;
if (!Array.isArray(usersId)) {
throw new CustomError(400, 'usersId must be an array');
}
const response = await sendMulticastFirebaseMessage(
validatedMessage,
usersId
);
res.status(200).json({
status: response.status,
message: response.message,
response: response.response,
});
} catch (error) {
if (error instanceof CustomError) {
res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
} else {
res.status(500).json({
status: 'error',
message: 'Failed to send message to Firebase',
error,
});
}
}
};
export {
checkRouteProtection,
checkUserLogged,
checkPubSubPublish,
checkPubSubPullSubscription,
checkPubsubPushSubscription,
checkPDFMake,
checkXMLBuilder,
checkFirebaseSingleNotification,
checkFirebaseMulticastNotification,
};

View File

@ -0,0 +1,73 @@
import express from 'express';
import catchAsyncHandler from '../../../middlewares/catchAsyncHandler.middleware';
import {requireAuthenticationMiddleware} from '../../../middlewares/requireAuthentication.middleware';
import {
checkPubSubPullSubscription,
checkPubsubPushSubscription,
checkPubSubPublish,
checkRouteProtection,
checkUserLogged,
checkPDFMake,
checkXMLBuilder,
checkFirebaseSingleNotification,
checkFirebaseMulticastNotification,
} from './app.controller';
const appRouter = express.Router();
appRouter.get(
'/test-route-protection',
requireAuthenticationMiddleware,
catchAsyncHandler(checkRouteProtection)
);
appRouter.get(
'/test-check-authenticated-user',
requireAuthenticationMiddleware,
catchAsyncHandler(checkUserLogged)
);
appRouter.post(
'/test-pubsub-publish',
requireAuthenticationMiddleware,
catchAsyncHandler(checkPubSubPublish)
);
appRouter.post(
'/test-pubsub-pull-subscription',
requireAuthenticationMiddleware,
catchAsyncHandler(checkPubSubPullSubscription)
);
appRouter.post(
'/test-pubsub-push-subscription',
requireAuthenticationMiddleware,
catchAsyncHandler(checkPubsubPushSubscription)
);
appRouter.post(
'/test-pdf-make',
requireAuthenticationMiddleware,
catchAsyncHandler(checkPDFMake)
);
appRouter.post(
'/test-xml-builder',
requireAuthenticationMiddleware,
catchAsyncHandler(checkXMLBuilder)
);
appRouter.post(
'/test-firebase-single-message',
requireAuthenticationMiddleware,
catchAsyncHandler(checkFirebaseSingleNotification)
);
appRouter.post(
'/test-firebase-multicast-message',
requireAuthenticationMiddleware,
catchAsyncHandler(checkFirebaseMulticastNotification)
);
export default appRouter;

View File

@ -0,0 +1,290 @@
import {NextFunction, Response} from 'express';
import {IVerifyOptions} from 'passport-local';
import {ICustomExpressRequest} from '../../../middlewares/currentUser.middleware';
import createCookieFromToken from '../../../utils/createCookieFromToken.utils';
import {CustomError} from '../../../errors';
import Logger from '../../../lib/logger';
import passport from '../../../config/passport.config';
import User, {IUserMethods} from '../user/user.model';
import {sendResetPasswordToken} from '../../../services/email/sparkpost.service';
/**
* Signup Local strategy
* @param req
* @param res
* @param next
* @returns
*/
const signup = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
): Promise<void> => {
return passport.authenticate(
'signup',
{session: false},
async (err: Error, user: IUserMethods, info: IVerifyOptions) => {
try {
if (err || !user) {
const {message} = info;
return res.status(400).json({
status: 'error',
error: {
message,
},
});
}
createCookieFromToken(user, 201, req, res);
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
}
)(req, res, next);
};
/**
* Login Local strategy
* @param req
* @param res
* @param next
*/
const login = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
passport.authenticate(
'login',
{session: false},
async (err: Error, user: IUserMethods, info: IVerifyOptions) => {
try {
if (err || !user) {
return res.status(401).json({
status: 'error',
error: {
message: info.message,
},
});
}
// call req.login manually to set the session and
// init passport correctly in serialize & deserialize
req.logIn(user, error => {
if (error) {
return next(error);
}
});
// generate a signed json web token with the contents of user
// object and return it in the response
createCookieFromToken(user, 200, req, res);
} catch (error) {
console.log(error);
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
}
)(req, res, next);
};
/**
* Logout
* @param req
* @param res
* @param next
*/
const logout = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
try {
res.clearCookie('jwt');
res.clearCookie('connect.sid');
req.session.destroy(error => {
if (error) {
return next(error);
}
return res.status(200).json({
status: 'success',
message: 'You have successfully logged out',
});
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
};
/**
* Recover password
* @param req
* @param res
* @returns
*/
const recoverPassword = async (req: ICustomExpressRequest, res: Response) => {
try {
const {email} = req.body;
const user = await User.findOne({email}).exec();
if (!user) {
return res.status(404).json({
status: 'error',
error: {
status: 'error',
message: 'User not found',
},
});
}
// Destroy session and remove any cookie
req.session.destroy(() => {
res.clearCookie('jwt');
});
res.clearCookie('jwt');
// Generate and set password reset token
user.generatePasswordResetToken();
// Save the updated user object with a resetPasswordToken and expire
await user.save();
// Send email to the user with the token
const sendEmail = await sendResetPasswordToken(
user.email as string,
user.resetPasswordToken as string
);
res.status(200).json({
status: 'success',
message: `A reset email has been sent to ${user.email}.`,
user: {
email: user.email,
token: user.resetPasswordToken,
},
emailStatus: sendEmail,
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
} else {
res.status(500).json({
status: 'error',
message: 'Email could not be sent',
error,
});
}
}
};
/**
* Reset password
* @param req
* @param res
* @param next
*/
const resetPassword = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
passport.authenticate(
'reset-password',
{session: false},
async (err: Error, user: IUserMethods, info: IVerifyOptions) => {
try {
if (err || !user) {
return res.status(400).json({
status: 'error',
error: {
message: info.message,
},
});
}
res.status(200).json({
status: 'success',
message: 'Password successfully updated',
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
}
)(req, res, next);
};
/**
* Return authenticated user
* @param req
* @param res
* @returns
*/
const returnUserLogged = async (req: ICustomExpressRequest, res: Response) => {
try {
if (!req.currentUser) {
return res.status(401).json({
status: 'error',
error: {
message:
'If you can see this message there is something wrong with authentication',
},
});
}
const user = await User.findById(req.currentUser?.id);
res.status(200).json({
status: 'success',
message: 'User logged retrieved',
data: {
user,
},
});
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
};
/**
* Google login
* @param req
* @param res
*/
const googleLogin = async (req: ICustomExpressRequest, res: Response) => {
try {
const user = req.user as IUserMethods;
createCookieFromToken(user, 201, req, res);
} catch (error) {
Logger.debug(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
};
export {
signup,
login,
logout,
recoverPassword,
resetPassword,
returnUserLogged,
googleLogin,
};

View File

@ -0,0 +1,61 @@
import express from 'express';
import passport from '../../../config/passport.config';
import {
recoverPasswordApiLimiter,
resetPasswordApiLimiter,
} from '../../../middlewares/apiRateLimit.middleware';
import catchAsyncHandler from '../../../middlewares/catchAsyncHandler.middleware';
import {requireAuthenticationMiddleware} from '../../../middlewares/requireAuthentication.middleware';
import {
googleLogin,
login,
logout,
recoverPassword,
resetPassword,
returnUserLogged,
signup,
} from './auth.controller';
const authRouter = express.Router();
authRouter.post('/signup', catchAsyncHandler(signup));
authRouter.post('/login', catchAsyncHandler(login));
authRouter.post('/logout', catchAsyncHandler(logout));
authRouter.post(
'/recover-password',
recoverPasswordApiLimiter,
catchAsyncHandler(recoverPassword)
);
authRouter.post(
'/reset-password',
resetPasswordApiLimiter,
catchAsyncHandler(resetPassword)
);
authRouter.get(
'/me',
requireAuthenticationMiddleware,
catchAsyncHandler(returnUserLogged)
);
/**
* Social Authentication: Google
*/
authRouter.get(
'/google',
passport.authenticate('google', {
session: false,
scope: ['profile', 'email'],
})
);
// callback route for Google authentication
authRouter.get(
'/google/callback',
passport.authenticate('google', {
session: false,
scope: ['profile', 'email'],
}),
googleLogin
);
export default authRouter;

View File

@ -0,0 +1,106 @@
import dotenv from 'dotenv';
import mongoose, {
Types,
Document,
HydratedDocument,
Model,
Schema,
} from 'mongoose';
import {CustomError} from '../../../errors';
dotenv.config();
if (!process.env.JWT_KEY) {
throw new CustomError(
404,
'Please provide a JWT_KEY as global environment variable'
);
}
export interface IDatabaseLog {
_id: Types.ObjectId;
type: string;
date: Date;
level: string;
details: {
channel: string;
message: string;
status: string;
response?: Schema.Types.Mixed;
};
}
export interface IDatabaseLogMethods {
toJSON(): Document<this>;
}
export interface IDatabaseLogModel
extends Model<IDatabaseLog, {}, IDatabaseLogMethods> {
checkExistingField: (
field: string,
value: string
) => Promise<HydratedDocument<IDatabaseLog, IDatabaseLogMethods>>;
}
const DatabaseLogSchema = new Schema<
IDatabaseLog,
IDatabaseLogModel,
IDatabaseLogMethods
>(
{
type: {type: String, required: true},
date: {type: Date, required: true},
level: {type: String, required: true},
details: {
channel: {type: String, required: true},
message: {type: String, required: true},
status: {type: String, required: true},
response: Schema.Types.Mixed,
},
},
{
toJSON: {
virtuals: true,
getters: true,
},
toObject: {
virtuals: true,
getters: true,
},
timestamps: true,
}
);
DatabaseLogSchema.index({
type: 1,
date: 1,
level: 1,
'details.channel': 1,
'details.message': 1,
'details.status': 1,
});
DatabaseLogSchema.methods.toJSON = function () {
const logObj = this.toObject();
logObj.id = logObj._id; // remap _id to id
delete logObj._id;
delete logObj.__v;
return logObj;
};
DatabaseLogSchema.statics.checkExistingField = async function (
field: string,
value: string
) {
const log = await this.findOne({[field]: value});
return log;
};
const DatabaseLog = mongoose.model<IDatabaseLog, IDatabaseLogModel>(
'DatabaseLog',
DatabaseLogSchema,
'logs'
);
export default DatabaseLog;

59
src/api/v1/index.route.ts Normal file
View File

@ -0,0 +1,59 @@
import express, {Response} from 'express';
import _ from 'lodash';
import {ICustomExpressRequest} from '../../middlewares/currentUser.middleware';
import appRouter from './app/app.route';
import authRouter from './auth/auth.route';
import swaggerRouter from './swagger/swagger.route';
import typedocRouter from './typedoc/typedoc.route';
import {
apiV1RateLimiter,
devlopmentApiLimiter,
} from '../../middlewares/apiRateLimit.middleware';
const apiV1Router = express.Router();
apiV1Router.get('/', (req: ICustomExpressRequest, res: Response) => {
res.status(200).json({
status: 'success',
message: 'Healthy check completed successfully',
});
});
const defaultRoutes = [
{
path: '/app',
route: appRouter,
},
{
path: '/auth',
route: authRouter,
},
];
const devRoutes = [
{
path: '/documentation',
route: swaggerRouter,
},
{
path: '/typedoc', // this route will serve typedoc generated documentation
route: typedocRouter,
},
];
_.forEach(defaultRoutes, route => {
apiV1Router.use(apiV1RateLimiter);
apiV1Router.use(route.path, route.route);
});
if (process.env.NODE_ENV === 'development') {
_.forEach(devRoutes, route => {
apiV1Router.use(devlopmentApiLimiter);
apiV1Router.use(route.path, route.route);
});
}
export default apiV1Router;

View File

@ -0,0 +1,136 @@
{
"openapi": "3.0.0",
"info": {
"title": "Express Typescript Rest Api",
"description": "Express Typescript Rest Api",
"termsOfService": "https://github.com/giuseppealbrizio/express-typescript-rest-api",
"contact": {
"email": "g.albrizio@gmail.com"
},
"license": {
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "1.0.0"
},
"externalDocs": {
"description": "Find out more about this template",
"url": "https://github.com/giuseppealbrizio"
},
"servers": [
{
"url": "http://localhost:3000/api/v1"
},
{
"url": "http://localhost:3000/api/v1"
}
],
"tags": [
{
"name": "App",
"description": "App routes"
}
],
"paths": {
"/app": {
"get": {
"tags": ["App"],
"summary": "App router",
"operationId": "appTest",
"responses": {
"200": {
"description": "successful operation",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"message": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Missing credentials",
"content": {}
},
"401": {
"description": "Invalid token, please log in or sign up",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string"
},
"error": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"trace": {
"type": "object",
"properties": {
"statusCode": {
"type": "number"
}
}
}
}
}
}
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
],
"x-codegen-request-body-name": "body"
}
}
},
"components": {
"schemas": {
"App": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"username": {
"type": "string"
},
"email": {
"type": "string"
},
"password": {
"type": "string"
}
},
"xml": {
"name": "User"
}
}
},
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
}
}

View File

@ -0,0 +1,14 @@
import express from 'express';
import swaggerUi from 'swagger-ui-express';
import swaggerDocument from './swagger.json';
const swaggerRouter = express.Router();
const options = {
explorer: true,
};
swaggerRouter.use('/', swaggerUi.serve);
swaggerRouter.get('/', swaggerUi.setup(swaggerDocument, options));
export default swaggerRouter;

View File

@ -0,0 +1,12 @@
import express from 'express';
import path from 'path';
const typedocRouter = express.Router();
typedocRouter.use(express.static(path.join(__dirname, '../../../../docs')));
typedocRouter.get('/typedoc', (req, res) => {
res.sendFile(path.join(__dirname, '../../../../docs/index.html'));
});
export default typedocRouter;

View File

@ -0,0 +1,340 @@
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
import mongoose, {
HydratedDocument,
Document,
Model,
Schema,
Types,
} from 'mongoose';
import validator from 'validator';
import {CustomError} from '../../../errors';
import {apiRoles} from '../../config/roles.config';
dotenv.config();
if (!process.env.JWT_KEY) {
throw new CustomError(
404,
'Please provide a JWT_KEY as global environment variable'
);
}
const jwtKey = process.env.JWT_KEY;
/**
* Define the Google Passport interface
*/
export interface IGooglePassport {
id: string;
sync: boolean;
tokens: {
accessToken: string;
refreshToken: string;
};
}
/**
* define user messages interface
*/
export interface IUserMessages {
title: string;
body: string;
type: string;
read: boolean;
firebaseMessageId: string;
}
/**
* Define the User model...
*/
export interface IUser {
// isModified(arg0: string): unknown;
_id: Types.ObjectId;
username: string;
fullName: string;
email: string;
password: string;
resetPasswordToken?: string;
resetPasswordExpires?: Date;
google: IGooglePassport;
role: string;
active: boolean;
pictureUrl: string;
pictureBlob: string;
lastLoginDate: Date;
notification: {
fcmPermission: string;
firebaseMessageToken: string;
};
messages: IUserMessages[];
featureFlags?: {
[key: string]: string;
};
}
/**
* Exporting methods for User
*/
export interface IUserMethods {
toJSON(): Document<this>;
comparePassword(password: string): Promise<boolean>;
generateVerificationToken(): string;
generatePasswordResetToken(): void;
}
/**
* Create a new Model type that knows about Methods and stati and IUser...
*/
export interface IUserModel extends Model<IUser, {}, IUserMethods> {
checkExistingField: (
field: string,
value: string
) => Promise<HydratedDocument<IUser, IUserMethods>>;
}
const MessageSchema = new Schema(
{
title: {
type: String,
required: true,
trim: true,
},
body: {
type: String,
required: true,
trim: true,
},
type: {
type: String,
required: true,
trim: true,
},
read: {
type: Boolean,
default: false,
},
firebaseMessageId: {
type: String,
},
},
{
toJSON: {
virtuals: true,
getters: true,
},
toObject: {
virtuals: true,
getters: true,
},
timestamps: true,
}
);
const UserSchema = new Schema<IUser, IUserModel, IUserMethods>(
{
username: {
type: String,
required: true,
unique: true,
lowercase: true,
index: true,
},
fullName: {
type: String,
},
email: {
type: String,
required: [true, "Email can't be blank"],
unique: true,
lowercase: true,
index: true,
// TODO: Re-enable the validation once migration is completed
validate: [validator.isEmail, 'Please provide an email address'],
match: [/\S+@\S+\.\S+/, 'is invalid'],
trim: true,
},
password: {type: String, required: true, minlength: 8},
resetPasswordToken: {
type: String,
required: false,
},
resetPasswordExpires: {
type: Date,
required: false,
},
google: {
id: String,
sync: {type: Boolean}, // authorisation to sync with google
tokens: {
accessToken: String,
refreshToken: String,
},
},
role: {
type: String,
enum: apiRoles,
default: 'user',
},
active: {
type: Boolean,
default: true,
},
pictureUrl: {
type: String,
trim: true,
validate: {
validator: (value: string) =>
validator.isURL(value, {
protocols: ['http', 'https', 'ftp'],
require_tld: true,
require_protocol: true,
}),
message: 'Must be a Valid URL',
},
},
pictureBlob: {
type: String,
},
lastLoginDate: {type: Date, required: false, default: null},
notification: {
fcmPermission: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
firebaseMessageToken: {type: String, trim: true, default: null},
},
messages: [MessageSchema],
featureFlags: {
allowSendEmail: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'granted',
},
allowSendSms: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'granted',
},
betaFeatures: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
darkMode: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
personalization: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
geolocationBased: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
security: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
payment: {
type: String,
enum: ['granted', 'denied', 'default'],
default: 'default',
},
},
},
{
toJSON: {
virtuals: true,
getters: true,
},
toObject: {
virtuals: true,
getters: true,
},
timestamps: true,
}
);
UserSchema.index({username: 1, email: 1, googleId: 1});
/**
* MONGOOSE MIDDLEWARE
*/
UserSchema.pre<HydratedDocument<IUser, IUserMethods>>(
'save',
async function (next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
}
);
/**
* MONGOOSE METHODS
*/
UserSchema.methods.toJSON = function () {
const userObj = this.toObject();
userObj.id = userObj._id; // remap _id to id
delete userObj._id;
delete userObj.password;
delete userObj.__v;
return userObj;
};
UserSchema.methods.comparePassword = async function (password: string) {
return bcrypt.compare(password, this.password);
};
UserSchema.methods.generateVerificationToken = function () {
return jwt.sign(
{
id: this._id,
email: this.email,
active: this.active,
role: this.role,
employeeId: this.employeeId,
clientId: this.clientId,
vendorId: this.vendorId,
deleted: this.deleted,
featureFlags: this.featureFlags,
},
jwtKey,
{
expiresIn: '1d',
// algorithm: 'RS256',
}
);
};
UserSchema.methods.generatePasswordResetToken = async function () {
this.resetPasswordToken = crypto.randomBytes(20).toString('hex');
this.resetPasswordExpires = Date.now() + 3600000; // expires in an hour
};
/**
* MONGOOSE STATIC METHODS
*/
UserSchema.statics.checkExistingField = async function (
field: string,
value: string
) {
return this.findOne({[`${field}`]: value});
};
const User = mongoose.model<IUser, IUserModel>('User', UserSchema, 'users');
export default User;

41
src/bin/server.ts Normal file
View File

@ -0,0 +1,41 @@
import http from 'http';
import app from '../index';
import Logger from '../lib/logger';
const port = process.env.PORT || 3000;
app.set('port', port);
const server = http.createServer(app);
const onError = (error: NodeJS.ErrnoException): void => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(`${bind} requires elevated privileges`);
process.exit(1);
case 'EADDRINUSE':
console.error(`${bind} is already in use`);
process.exit(1);
default:
throw error;
}
};
const onListening = (): void => {
const addr = server.address();
const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr?.port}`;
Logger.debug(`Listening on ${bind}`);
Logger.info(`🚀 Server listening on port ${bind}`);
};
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

119
src/config/gcloud/README.md Normal file
View File

@ -0,0 +1,119 @@
This folder contains all the config files to link the project to Google Cloud Platform
## **Basic Concepts**
### 1. SERVICE ACCOUNT
To manage things like upload file to Cloud Storage, etc...
- Go to GCP and create a new project
- Go to Service Account and create one service account with a descriptive name
- Assign a role you want to the service account (i.e. "Storage Admin")
- Create a JSON key, download and put in `./src/config/gcloud` renaming google-application-credentials.json
- In the .env file specify the path to this file like
- In this way we can configure the app to be linked with the service account created
```dotenv
GOOGLE_APPLICATION_CREDENTIALS='./../config/gcloud/google-application-credentials.json'
```
### 2. OAUTH Client ID Account
i.e. Used to create passport strategies with google
- Go to GCP
- Go to API & Services and create one OAuth Client ID account
- Choose Application Type -> Web application
- Name the web client (i.e. Dev Test - Web Client Oauth 2.0 Account)
- In Authorized javascript origins put
```
Authorized JavaScript origins
URIs*
http://localhost:3000
Authorized redirect URIs
URIs*
http://localhost:3000/auth/google/callback
```
- Copy the google client id and the google client secret and put them in the .env file like
```dotenv
GOOGLE_CLIENT_ID='your-google-client-id'
GOOGLE_CLIENT_SECRET='your-client-secret'
```
- Download the json file and rename to google-web-client-secret.json
- Then if you need to import the file in a middleware like passport or something else you can do
```javascript
const OAuth2Data = require('./google-web-client-secret.json');
const app = express();
const CLIENT_ID = OAuth2Data.client.id;
const CLIENT_SECRET = OAuth2Data.client.secret;
const REDIRECT_URL = OAuth2Data.client.redirect;
const oAuth2Client = new google.auth.OAuth2(
CLIENT_ID,
CLIENT_SECRET,
REDIRECT_URL,
);
var authed = false;
app.get('/', (req, res) => {
if (!authed) {
// Generate an OAuth URL and redirect there
const url = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: 'https://www.googleapis.com/auth/gmail.readonly',
});
console.log(url);
res.redirect(url);
} else {
const gmail = google.gmail({ version: 'v1', auth: oAuth2Client });
gmail.users.labels.list(
{
userId: 'me',
},
(err, res) => {
if (err) return console.log('The API returned an error: ' + err);
const labels = res.data.labels;
if (labels.length) {
console.log('Labels:');
labels.forEach((label) => {
console.log(`- ${label.name}`);
});
} else {
console.log('No labels found.');
}
},
);
res.send('Logged in');
}
});
app.get('/auth/google/callback', function (req, res) {
const code = req.query.code;
if (code) {
// Get an access token based on our OAuth code
oAuth2Client.getToken(code, function (err, tokens) {
if (err) {
console.log('Error authenticating');
console.log(err);
} else {
console.log('Successfully authenticated');
oAuth2Client.setCredentials(tokens);
authed = true;
res.redirect('/');
}
});
}
});
```

View File

@ -0,0 +1,12 @@
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "your-private-key",
"client_email": "service-account-email",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "client_x509_cert_url"
}

View File

@ -0,0 +1,14 @@
{
"type": "service_account",
"project_id": "diane-456005",
"private_key_id": "8daa020ca93921e2c45470db1bf511939de4beef",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDFBoJ4S8/D6s00\nAfyHbY3dZpCQqDOcc7x6Bd9gx5y0FPUp6ZIbOHAhPqokp2EK3g9vYPkPQDjexC/2\nbrFU/dpZQpItFcbqCd9/lPJsNq+fgNSGCIdG8rN3pEol98RmkVnAKjmV1o3TpJc0\nvpP5i4OHd0dyx/c6gfNeyTbaWwzIjKCrcGFovBrfOozQzm8oOwcZxc5sFpYv8T4Y\nEqfueZSNAOgImJDN7JaJB9tpg5nfFJwEuDsERKd6oQIQvp9NJ3Kw2MqOj6r4RlL3\nB7Euk7bJPz/HfBWEhyymQADd27YHKHPD1Fw2eOG5CXYp0SFQitnpDZLgUI5OIKYs\nKgcCVq93AgMBAAECggEAHd4I7OaFMOxzvg9nsjn72FUXfi5ZYJK06YSMDSDwEuaI\nbG7xd088xEq6bS2zICbIjThUmSjBq5dvCTr/ho79niwMJRSDinADtv9Ofp64TIQx\nnhNF5Tiegc5gb9VZqw4cCRhS5sbXH38jGDD5v/RpHyHdrWXohwXdwv/atmA4wF/j\nlMGOcX0FEORQbZ9a1q3HsQXJVBrdmrFsmd8lCdxXBNPmO2iXANbw8SAVmvuQVArk\n2X9Q5wJPc3+bSZDVF7GZXaoPnrYtwSMGUH2gNtg3QLWmUxpkMtmG/rRhU0cSwTfl\nZmeIsl/raoRUBk2Vd+NfBZr/w/6XtT6Ze1/hXxbV6QKBgQDsTuaIOiOKFwCtnDez\nW2Xo3WZglojJ3/sdvhW16bDkZ1eWiGXva7GO00WKta4cPyP3KoxXTnZxOLAcHfco\nZiphJkjB0lQflMhz5u9De+JU95WrKAIl0s1/Sv6BIzBfZcIL1XXtzw0tq761bRtS\nNC9uItgREr4gX877HA20hHvfiQKBgQDVcZmm34/5j6JbW13Ul6CUphhGVs0AXK50\nM4gdrqqYIIkjEVB9Ni+hD9Ad2iAA58D3rWvSgYnURpPnLS3XM10wnoJGeygXMEvo\nmdSvBCN1XsQJ/l2Lj/5aSaAOz2F3GWA5EZdNgUe1/8DVU54CeKP1wxcueD/dIMTb\nnVo6RUNW/wKBgHGKY4f271aNQN0p3zWFZ8zgfC1Shv0Aaoba61GRrFXCNbp1ZQ0J\noLGwX4yLSNH3oI9E2VOltpEmHLAV0ciOdjRhkbnXFmZqNXpC7pltL82FfFtViNql\nk+linjBsOPTNTtQix1vxDTLxf1tqxiLUQinYAhsJ92JUxn2u+ALRWTeJAoGAYati\n+RZSBouwaoeLjy13IK5Ea2Nq2WCPv8KY5aQ4kfZJao+QuksiTlwzCoX2oRNrnKpx\nrVjzXfyRz3ZABLqPSSEvUdsnRD0obx59UTzekOW1ZTFNUwCoDl6kbEJ/QgWNn2+q\nQaAH1YNblQJ3SoAz3tDP+cayypglHK2LTSDGqLcCgYAij/uPfaTFE3fSvmBuR7Q2\nd1MrirtqsUPfqjRxo3RfROIKyoA2VRmO5i3CYdOLTdiWPNxt0hB1GqHvjlrzckvv\nBxhFC9x8Qxvbr7JQV36fTu8b9oUB5HhRZLTNQZ5T8jXGN4D1UmcczNUqOSnRVJq9\nt+vHGFvscoac5+R980mV2g==\n-----END PRIVATE KEY-----\n",
"client_email": "diane-serviceaccount@diane-456005.iam.gserviceaccount.com",
"client_id": "114388034961450557627",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/diane-serviceaccount%40diane-456005.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1,12 @@
{
"web": {
"client_id": "your-google-client-id",
"project_id": "your-google-project-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "your-google-client-secret",
"redirect_uris": ["http://localhost:3000/auth/google/callback"],
"javascript_origins": ["http://localhost:3000"]
}
}

View File

@ -0,0 +1,52 @@
import mongoose from 'mongoose';
import Logger from '../lib/logger';
import {CustomError} from '../errors/CustomError.error';
mongoose.set('strictQuery', true);
mongoose.connection.on('connected', () => {
Logger.info('MongoDB connection established');
});
mongoose.connection.on('reconnected', () => {
Logger.warn('MongoDB reconnected');
});
mongoose.connection.on('disconnected', () => {
Logger.warn('MongoDB disconnected');
});
mongoose.connection.on('close', () => {
Logger.warn('MongoDB connection closed');
});
mongoose.connection.on('error', (error: string) => {
Logger.error(`🤦🏻 MongoDB ERROR: ${error}`);
process.exit(1);
});
export default {
mongoDbProdConnection: async () => {
try {
await mongoose.connect(<string>process.env.MONGO_URI);
Logger.info(`Connected to db: ${mongoose.connection.name}`);
} catch (error) {
Logger.error(`Production - MongoDB connection error. ${error}`);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
}
},
mongoDBTestConnection: async () => {
try {
await mongoose.connect(<string>process.env.MONGO_URI_TEST);
Logger.info(`Connected to db: ${mongoose.connection.name}`);
} catch (error) {
Logger.error('Test - MongoDB connection error' + error);
if (error instanceof CustomError) {
throw new CustomError(500, error.message);
}
}
},
};

View File

@ -0,0 +1,45 @@
import {createPool, Pool} from 'mysql2';
/**
* If you would like to run the inserts asynchronously, you will want createPool.
* Because in with createConnection, there is only 1 connection and all queries
* executed on that connection are queued, and that is not really asynchronous.
* (Async from node.js perspective, but the queries are executed sequentially)
* @type {Pool}
*/
const mySqlTestConnection: Pool = createPool({
host: process.env.MYSQL_HOST_STAGE,
user: process.env.MYSQL_USER_STAGE,
password: process.env.MYSQL_PASSWORD_STAGE,
database: process.env.MYSQL_DB_STAGE,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// socketPath:
// process.env.NODE_ENV !== 'production' ? '' : process.env.MYSQL_SOCKET_STAGE,
});
const mySqlProdConnection: Pool = createPool({
host: process.env.MYSQL_HOST_PROD,
user: process.env.MYSQL_USER_PROD,
password: process.env.MYSQL_PASSWORD_PROD,
database: process.env.MYSQL_DB_PROD,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// socketPath:
// process.env.NODE_ENV !== 'production' ? '' : process.env.MYSQL_SOCKET_PROD,
});
// TODO: When ready uncomment this and use the prod db
export const mySqlConnection =
process.env.NODE_ENV !== 'production'
? mySqlTestConnection.promise()
: mySqlProdConnection.promise();
/**
* Example of query on pre-existing database
*/
// const query = `# SELECT * FROM users`;
// const [rows] = await connection.execute(query, [limit]);

View File

@ -0,0 +1,236 @@
import dotenv from 'dotenv';
import passport from 'passport';
import passportLocal, {IStrategyOptionsWithRequest} from 'passport-local';
import passportGoogle from 'passport-google-oauth20';
import User, {IUser} from '../api/v1/user/user.model';
import Logger from '../lib/logger';
import {ICustomExpressRequest} from '../middlewares/currentUser.middleware';
dotenv.config();
const {GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET} = process.env;
const LocalStrategy = passportLocal.Strategy;
const GoogleStrategy = passportGoogle.Strategy;
passport.serializeUser((user, done) => {
/* Store only the id in passport req.session.passport.user */
done(null, user);
});
passport.deserializeUser((id, done) => {
User.findOne({_id: id}, (err: NativeError, user: IUser) => {
done(null, user);
});
});
const authFields: IStrategyOptionsWithRequest = {
usernameField: 'email',
passwordField: 'password',
passReqToCallback: true,
};
/**
* Login strategy
*/
passport.use(
'login',
new LocalStrategy(
authFields,
async (req: ICustomExpressRequest, email, password, cb) => {
try {
const user = await User.findOne({
$or: [{email}, {username: email.toLowerCase()}],
}).exec();
if (!user || !user.password) {
return cb(null, false, {message: 'User not found.'});
}
const checkPassword = await user.comparePassword(password);
if (!checkPassword) {
return cb(null, false, {message: 'Incorrect email or password.'});
}
if (!user || !user.active) {
return cb(null, false, {message: 'Account is deactivated.'});
}
const {active} = user;
if (!active) {
return cb(null, false, {message: 'Account is deactivated.'});
}
user.lastLoginDate = new Date();
await user.save();
return cb(null, user, {message: 'Logged In Successfully'});
} catch (err: unknown) {
if (err instanceof Error) {
Logger.debug(err);
return cb(null, false, {message: err.message});
}
}
}
)
);
/**
* Sign up strategy
*/
passport.use(
'signup',
new LocalStrategy(authFields, async (req, email, password, cb) => {
try {
const checkEmail = await User.checkExistingField('email', email);
if (checkEmail) {
return cb(null, false, {
message: 'Email already registered, log in instead',
});
}
const checkUserName = await User.checkExistingField(
'username',
req.body.username
);
if (checkUserName) {
return cb(null, false, {
message: 'Username exists, please try another',
});
}
const newUser = new User();
newUser.email = req.body.email;
newUser.password = req.body.password;
newUser.username = req.body.username;
await newUser.save();
return cb(null, newUser);
} catch (err: unknown) {
if (err instanceof Error) {
Logger.debug(err);
return cb(null, false, {message: err.message});
}
}
})
);
/**
* The password Reset method is with a token generated
*/
passport.use(
'reset-password',
new LocalStrategy(authFields, async (req, email, password, cb) => {
try {
const {token} = await req.body;
const user = await User.findOne({
resetPasswordToken: token,
resetPasswordExpires: {$gt: Date.now()},
}).exec();
if (!user) {
return cb(null, false, {
message: 'Password reset token is invalid or has expired.',
});
}
user.password = password;
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
return cb(null, user, {message: 'Password Changed Successfully'});
} catch (err: unknown) {
if (err instanceof Error) {
Logger.debug(err);
return cb(null, false, {message: err.message});
}
}
})
);
/**
* Google strategy
*/
passport.use(
'google',
new GoogleStrategy(
{
clientID: <string>GOOGLE_CLIENT_ID,
clientSecret: <string>GOOGLE_CLIENT_SECRET,
callbackURL: `/api/v1/${process.env.SERVICE_NAME}/auth/google/callback`,
},
async (accessToken, refreshToken, profile, done) => {
try {
const username = profile.emails && profile?.emails[0]?.value;
const email = profile.emails && profile?.emails[0]?.value;
const pictureUrl = profile.photos && profile.photos[0].value;
// 1. Check if user has already a Google profile and return it
const googleUser = await User.findOne({
'google.id': profile.id,
}).exec();
if (googleUser) {
return done(null, googleUser, {statusCode: 200});
}
// 2. If user email is in the db and tries to google auth
// update only with Google id and token
const checkEmail = await User.checkExistingField(
'email',
<string>email
);
const fieldsToUpdate = {
pictureUrl,
'google.id': profile.id,
'google.sync': true,
'google.tokens.accessToken': accessToken,
'google.tokens.refreshToken': refreshToken,
};
if (checkEmail) {
const user = await User.findByIdAndUpdate(
checkEmail._id,
fieldsToUpdate,
{new: true}
).exec();
return done(null, <IUser>user, {statusCode: 200});
}
// 3. If nothing before is verified create a new User
const userObj = new User({
username, // the same as the email
email,
pictureUrl,
password: accessToken,
'google.id': profile.id,
'google.sync': true,
'google.tokens.accessToken': accessToken,
'google.tokens.refreshToken': refreshToken,
});
const user = await userObj.save({validateBeforeSave: false});
return done(null, user, {statusCode: 201});
} catch (err: unknown) {
if (err instanceof Error) {
Logger.debug(err);
return done(err, false);
}
}
}
)
);
export default passport;

View File

@ -0,0 +1,12 @@
export class CustomError extends Error {
public statusCode: number;
public message: string;
constructor(statusCode: number, message: string) {
super(message);
Error.captureStackTrace(this, this.constructor);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.message = message;
}
}

View File

@ -0,0 +1,7 @@
import {CustomError} from './CustomError.error';
export class NotAuthorizedError extends CustomError {
constructor(message: string) {
super(401, message);
}
}

View File

@ -0,0 +1,7 @@
import {CustomError} from './CustomError.error';
export class NotFoundError extends CustomError {
constructor(message: string) {
super(404, message);
}
}

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

@ -0,0 +1,3 @@
export * from './CustomError.error';
export * from './NotAuthorized.error';
export * from './NotFound.error';

193
src/index.ts Normal file
View File

@ -0,0 +1,193 @@
import dotenv from 'dotenv';
import express from 'express';
import compression from 'compression';
import helmet from 'helmet';
import xss from 'xss-clean';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import mongoSanitize from 'express-mongo-sanitize';
import session from 'express-session';
import MongoStore from 'connect-mongo';
import passport from 'passport';
import {initializeApp, applicationDefault} from 'firebase-admin/app';
import mongoose from 'mongoose';
import Logger from './lib/logger';
import morganMiddleware from './middlewares/morgan.middleware';
import {currentUserMiddleware} from './middlewares/currentUser.middleware';
import errorHandleMiddleware from './middlewares/errorHandler.middleware';
import {NotFoundError} from './errors/NotFound.error';
import apiV1Router from './api/v1/index.route';
import mongoDbConfiguration from './config/mongodb.config';
// import path from 'path';
dotenv.config();
const {mongoDBTestConnection, mongoDbProdConnection} = mongoDbConfiguration;
/**
* Connect to MongoDB
*/
if (process.env.NODE_ENV === 'development') {
mongoDBTestConnection().catch(error => {
Logger.error(error.message);
});
} else {
mongoDbProdConnection().catch(error => {
Logger.error(error.message);
});
}
/**
* Import agenda jobs
*/
import './jobs/agenda';
/**
* Initialize Firebase Admin SDK
*/
initializeApp({
credential: applicationDefault(),
});
/**
* Initialize express app
*/
const app = express();
// trust proxy
app.set('trust proxy', true);
// logger middleware
app.use(morganMiddleware);
// set security HTTP headers
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false, // set this false to prevent bug in new browser
})
);
// parse body request
app.use(express.json());
// parse urlencoded request
app.use(express.urlencoded({extended: true}));
// sanitize
app.use(xss());
app.use(mongoSanitize());
// use GZIP compression
app.use(compression());
// parse cookie
app.use(cookieParser());
// Cookie policy definition
const COOKIE_MAX_AGE: string | number =
process.env.COOKIE_MAX_AGE || 1000 * 60 * 60 * 24;
const SECRET = <string>process.env.JWT_KEY;
/**
* FIX:
* We reusing the mongoose connection to avoid the error:
* workaround for Jest that crashes when using mongoUrl option
*/
const mongoStore = MongoStore.create({
client: mongoose.connection.getClient(),
stringify: false,
autoRemove: 'interval',
autoRemoveInterval: 1,
});
app.use(
session({
cookie: {
// secure: DEFAULT_ENV === 'production',
maxAge: <number>COOKIE_MAX_AGE,
httpOnly: true,
sameSite: 'lax',
},
secret: SECRET,
resave: false,
saveUninitialized: false,
/* Store session in mongodb */
store: mongoStore,
unset: 'destroy',
})
);
/**
* currentUser middleware. It will set the current user in the request
*/
app.use(currentUserMiddleware);
/**
* Initialize Passport and pass the session to session storage of express
* Strategies are called in the auth router
* and in ./src/config/passport.config.ts
*/
app.use(passport.initialize());
app.use(passport.session());
/**
* CORS configuration
*/
app.use(
cors({
origin: process.env.CLIENT_URL || '*', // allow CORS
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // allow session cookie from browser to pass through
})
);
/**
* Headers configuration
*/
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', process.env.CLIENT_URL); // Update to match the domain you will make the request from
res.header(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept'
);
next();
});
/**
* This MIDDLEWARE is to serve the public client build and redirect everything
* to the client index.html. Replace the original one with public. Move build
* inside the server folder and activate also the catchall middleware.
*/
// app.use(
// express.static(path.join(__dirname, '../public'), {
// index: 'index.html',
// })
// );
/**
* Routes definitions
*/
app.use(`/api/v1/${process.env.SERVICE_NAME}`, apiV1Router);
/**
* Catchall middleware. Activate to serve every route in
* the public directory i.e. if we have a build of React
*/
// app.use((req, res) =>
// res.sendFile(path.resolve(path.join(__dirname, '../public/index.html')))
// );
/**
* Catchall middleware. Activate to serve every route in throw an error if the route is not found
*/
app.all('*', () => {
throw new NotFoundError('Route not found');
});
/**
* Global Error handler middleware
*/
app.use(errorHandleMiddleware);
export default app;

54
src/jobs/agenda.ts Normal file
View File

@ -0,0 +1,54 @@
import dotenv from 'dotenv';
import {Agenda} from '@hokify/agenda';
import Logger from '../lib/logger';
dotenv.config();
const {MONGO_URI, MONGO_URI_TEST} = process.env;
interface AgendaDBOptions {
address: string;
collection?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: any;
ensureIndex?: boolean;
}
const agenda = new Agenda({
db: {
address:
<string>process.env.NODE_ENV === 'production'
? <string>MONGO_URI
: <string>MONGO_URI_TEST,
collection: 'agendaJobs',
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
},
ensureIndex: true,
// maxConcurrency: 20,
} as AgendaDBOptions,
});
/**
* CRON JOB
* @description Check if agenda is working
*/
agenda.define('check_agenda_status', async job => {
Logger.info('Agenda is working!', job.attrs.data);
});
(async function () {
const dailyAgendaStatusCheck = agenda.create('check_agenda_status');
await agenda.start();
dailyAgendaStatusCheck.repeatEvery('0 8 * * 1-7', {
skipImmediate: true,
timezone: 'Europe/Rome',
});
dailyAgendaStatusCheck.unique({jobId: 0});
await dailyAgendaStatusCheck.save();
})();

98
src/lib/logger.ts Normal file
View File

@ -0,0 +1,98 @@
import winston from 'winston';
/**
* Define your severity levels.
* With them, You can create log files,
*see or hide levels based on the running ENV.
*/
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
/**
* This method set the current severity based on
* the current NODE_ENV: show all the log levels
* if the server was run in development mode; otherwise,
* if it was run in production, show only warn and error messages.
*/
const level = () => {
const env = process.env.NODE_ENV || 'development';
const isDevelopment = env === 'development';
return isDevelopment ? 'debug' : 'warn';
};
/**
* Define different colors for each level.
* Colors make the log message more visible,
* adding the ability to focus or ignore messages.
*/
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
};
/**
* Tell winston that you want to link the colors
* defined above to the severity levels.
*/
winston.addColors(colors);
// Chose the aspect of your log customizing the log format.
const format = winston.format.combine(
// Add the message timestamp with the preferred format
winston.format.timestamp({format: 'YYYY-MM-DD HH:mm:ss:ms'}),
/**
* Tell Winston that the logs must be colored but
* we bypass this global formatting colorize because generates
* wrong output characters in file. Add in transports
*/
// winston.format.colorize({all: true}),
// Define the format of the message showing the timestamp, the level and the message
winston.format.printf(
info => `${info.timestamp} ${info.level}: ${info.message}`
)
);
/**
* Define which transports the logger must use to print out messages.
* In this example, we are using three different transports
*/
const transports = [
// Allow the use the console to print the messages
new winston.transports.Console({
format: winston.format.combine(
// Integration to format. Tell Winston that the console logs must be colored
winston.format.colorize({all: true})
),
}),
// Allow to print all the error level messages inside the error.log file
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
/**
* Allow to print all the error message inside the all.log file
* (also the error log that are also printed inside the error.log(
*/
new winston.transports.File({filename: 'logs/all.log'}),
];
/**
* Create the logger instance that has to be exported
* and used to log messages.
*/
const Logger = winston.createLogger({
level: level(),
levels,
format,
transports,
});
export default Logger;

View File

@ -0,0 +1,79 @@
import {Response} from 'express';
import rateLimit from 'express-rate-limit';
import {ICustomExpressRequest} from './currentUser.middleware';
/**
* Rate limiter for api v1
* @see https://www.npmjs.com/package/express-rate-limit
* @description 1000 requests per 1 minute for production
*/
const apiV1RateLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 200, // Limit each IP to 200 requests per `window` (here, per 1 minute)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: async (req: ICustomExpressRequest, res: Response) => {
return res.status(429).json({
status: 'error',
message: 'You have exceeded the 100 requests in 1 minute limit!',
});
},
});
/**
* Rate limiter for development route as typedoc and swagger
* @description 1000 requests per 1 hour for development
*/
const devlopmentApiLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 59 minute
max: 1000, // Limit each IP to 1000 requests per `window` (here, per 1 hour)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: async (req: ICustomExpressRequest, res: Response) => {
return res.status(429).json({
status: 'error',
message: 'Too many requests, please try again in 10 minutes.',
});
},
});
/**
* Rate limiter for recover password
*/
const recoverPasswordApiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 5 minute
max: 1, // Limit each IP to 1020 requests per `window` (here, per 1 minute)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: async (req: ICustomExpressRequest, res: Response) => {
return res.status(429).json({
status: 'error',
message:
'Too many requests to recover password, please try again in 1 minute.',
});
},
});
/**
* Rate limiter for reset password
*/
const resetPasswordApiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 10, // Limit each IP to 10 requests per `window` (here, per 1 minute)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
message: async (req: ICustomExpressRequest, res: Response) => {
return res.status(429).json({
status: 'error',
message:
'Too many requests to reset password, please try again in 1 minute.',
});
},
});
export {
apiV1RateLimiter,
devlopmentApiLimiter,
recoverPasswordApiLimiter,
resetPasswordApiLimiter,
};

View File

@ -0,0 +1,18 @@
import {NextFunction, Response} from 'express';
import {ICustomExpressRequest} from './currentUser.middleware';
/**
* A function that takes a request, response, and next function as parameters.
*/
export default (catchAsyncHandler: Function) =>
async (
request: ICustomExpressRequest,
response: Response,
next: NextFunction
): Promise<void> => {
try {
catchAsyncHandler(request, response, next);
} catch (error) {
return next(error);
}
};

View File

@ -0,0 +1,69 @@
/**
* This middleware differentiate from the authenticate one
* because is called after the authentication to retrieve
* the jwt token stored in the cookie. This is useful to be
* exported in a shared library
*/
import {NextFunction, Request, Response} from 'express';
import jwt from 'jsonwebtoken';
export interface ICurrentUserPayload {
id: string;
email: string;
active: boolean;
role: string;
employeeId: string;
clientId: string;
vendorId: string;
deleted: boolean;
featureFlags: {
allowSendEmail: string;
allowSendSms: string;
betaFeatures: string;
darkMode: string;
};
}
/**
* An interface representing the custom Express request object.
*/
export interface ICustomExpressRequest extends Request {
currentUser?: ICurrentUserPayload;
}
// const secretOrPrivateKey = <string>process.env.JWT_KEY;
export const currentUserMiddleware = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
if (!req.cookies?.jwt && !req.headers?.authorization) {
return next();
}
try {
if (
req.headers.authorization &&
req.headers.authorization.startsWith('Bearer ')
) {
const jwtFromBearer = req.headers?.authorization?.split(' ');
const jwtToken = jwtFromBearer[1];
req.currentUser = jwt.verify(
jwtToken,
// secretOrPrivateKey,
<string>process.env.JWT_KEY
) as ICurrentUserPayload;
} else if (req.cookies.jwt) {
req.currentUser = jwt.verify(
req.cookies.jwt,
// secretOrPrivateKey,
<string>process.env.JWT_KEY
) as ICurrentUserPayload;
}
} catch (error) {
return next(error);
}
return next();
};

View File

@ -0,0 +1,43 @@
/**
* This middleware is responsible for returning a json every time
* an error comes in. We use in the index.ts as global middleware
*/
import dotenv from 'dotenv';
import {NextFunction, Request, Response} from 'express';
import {CustomError} from '../errors';
import Logger from '../lib/logger';
dotenv.config();
const errorHandleMiddleware = (
err: CustomError,
req: Request,
res: Response,
next: NextFunction
) => {
const isProduction = process.env.NODE_ENV === 'production';
let errorMessage = {};
if (res.headersSent) {
return next(err);
}
if (!isProduction) {
Logger.debug(err.stack);
errorMessage = err;
}
if (err) {
return res.status(err.statusCode || 500).json({
status: 'error',
statusCode: err.statusCode,
message: err.message,
error: {
message: err.message,
...(!isProduction && {trace: errorMessage}),
},
});
}
};
export default errorHandleMiddleware;

View File

@ -0,0 +1,44 @@
import morgan, {StreamOptions} from 'morgan';
import Logger from '../lib/logger';
/**
* Override the stream method by telling
* Morgan to use our custom logger instead of the console.log.
*/
const stream: StreamOptions = {
// Use the http severity
write: message => Logger.http(message),
};
/**
*
* Skip all the Morgan http log if the
* application is not running in development mode.
* This method is not really needed here since
* we already told to the logger that it should print
* only warning and error messages in production.
*/
const skip = () => {
const env = process.env.NODE_ENV || 'development';
return env !== 'development';
};
// Build the morgan middleware
const morganMiddleware = morgan(
/**
* Define message format string (this is the default one).
* The message format is made from tokens, and each token is
* defined inside the Morgan library.
* You can create your custom token to show what do you want from a request.
*/
':method :url :status :res[content-length] - :response-time ms - :remote-addr - :user-agent - :date[iso]',
/**
* Options: in this case, I overwrote the stream and the skip logic.
* See the methods above.
*/
{stream, skip}
);
export default morganMiddleware;

View File

@ -0,0 +1,14 @@
import {ICustomExpressRequest} from './currentUser.middleware';
import {Response, NextFunction} from 'express';
import {NotAuthorizedError} from '../errors';
export const requireAdminRoleMiddleware = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
if (req.currentUser?.role !== 'admin') {
throw new NotAuthorizedError('You are not an admin!');
}
next();
};

View File

@ -0,0 +1,14 @@
import {Response, NextFunction} from 'express';
import {NotAuthorizedError} from '../errors';
import {ICustomExpressRequest} from './currentUser.middleware';
export const requireAuthenticationMiddleware = (
req: ICustomExpressRequest,
res: Response,
next: NextFunction
) => {
if (!req.currentUser) {
throw new NotAuthorizedError('You are not authorized! Please login!');
}
next();
};

View File

@ -0,0 +1,29 @@
import {NextFunction, Response} from 'express';
import {apiRolesRights} from '../api/config/roles.config';
import {NotAuthorizedError} from '../errors';
import {ICustomExpressRequest} from './currentUser.middleware';
export const verifyApiRights =
(...requiredRights: Array<string>) =>
(req: ICustomExpressRequest, res: Response, next: NextFunction) => {
if (requiredRights?.length) {
const userRights = <Array<string>>(
apiRolesRights.get(<string>req.currentUser?.role)
);
const hasRequiredRights = requiredRights.every((requiredRight: string) =>
userRights?.includes(requiredRight)
);
if (
!hasRequiredRights &&
req.params.userId !== <string>req.currentUser?.id
) {
throw new NotAuthorizedError(
'You are not authorized to use this endpoint'
);
}
}
next();
};

View File

@ -0,0 +1,57 @@
import SparkPost from 'sparkpost';
import {CustomError} from '../../errors';
import Logger from '../../lib/logger';
/**
* Send reset password token to user email
* @param email
* @param token
* @returns Promise<SparkPost.ResultsPromise<{ total_rejected_recipients: number; total_accepted_recipients: number; id: string; }>>
*/
const sendResetPasswordToken = async (
email: string,
token: string
): Promise<
SparkPost.ResultsPromise<{
total_rejected_recipients: number;
total_accepted_recipients: number;
id: string;
}>
> => {
const {SPARKPOST_API_KEY, SPARKPOST_SENDER_DOMAIN} = process.env;
try {
const euClient = new SparkPost(SPARKPOST_API_KEY, {
origin: 'https://api.eu.sparkpost.com:443',
});
const transmission = {
recipients: [
{
address: {
email,
name: email,
},
},
],
content: {
from: {
email: `support@${SPARKPOST_SENDER_DOMAIN}`,
name: 'Support Email',
},
subject: 'Reset your password',
reply_to: `support@${SPARKPOST_SENDER_DOMAIN}`,
text: `Hello ${email}, we heard you lost your password. You can recover with this token: ${token}`,
},
};
return await euClient.transmissions.send(transmission);
} catch (error) {
Logger.error(error);
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
// here we are throwing an error instead of returning it
throw error;
}
};
export {sendResetPasswordToken};

View File

@ -0,0 +1,193 @@
import {Message, PubSub, Subscription, Topic} from '@google-cloud/pubsub';
import DatabaseLog, {
IDatabaseLog,
} from '../../api/v1/database-logs/databaseLog.model';
import Logger from '../../lib/logger';
import {HydratedDocument} from 'mongoose';
const pubSubClient = new PubSub();
/**
* declare custom payload interface
*/
export interface IPubSubPayload<T> {
[key: string]: T;
}
/**
* declare custom error interface
*/
export interface IPubSubPublishError extends Error {
statusCode: number;
}
export type TPubSubMessage = Message;
/**
* declare custom error class for PubSub publish error
* We define a custom class since we want to throw a custom error with a custom status code
*/
class PubSubPublishError extends Error implements IPubSubPublishError {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
/**
* Publish message to PubSub
* @param payload
* @param topicName
* @returns
*/
const publishMessageToPubSubTopic = async <T>(
payload: IPubSubPayload<T>,
topicName: string
): Promise<string> => {
try {
const dataBuffer = Buffer.from(JSON.stringify(payload));
const topic: Topic = pubSubClient.topic(topicName);
if (!(await topic.exists())) {
throw new PubSubPublishError(`Topic ${topicName} does not exist`, 404);
}
const message = {
data: dataBuffer,
};
const response = await topic.publishMessage(message);
return response;
} catch (error) {
Logger.error(error);
if (error instanceof PubSubPublishError) {
throw error;
} else {
throw new PubSubPublishError(
`Failed to publish message to topic ${topicName} with error: ${error}`,
404
);
}
}
};
/**
*
* @param subscriptionName
* @returns {Promise<string>}
*/
const listenForPubSubPullSubscription = async (
subscriptionName: string,
timeout: number
): Promise<string> => {
try {
const subscriberOptions = {
flowControl: {
maxMessages: 10,
},
};
const subscription: Subscription = pubSubClient.subscription(
subscriptionName,
subscriberOptions
);
const checkSubscriptionExists = await subscription.exists();
/**
* Check if subscription exists
*/
if (!checkSubscriptionExists[0]) {
throw new PubSubPublishError(
`Subscription ${subscriptionName} does not exist`,
404
);
}
// Instantiate the message counter
let messageCount = 0;
/**
* Create an event handler to handle messages
* @param message
*/
const messageHandler = async (message: TPubSubMessage): Promise<void> => {
const data = Buffer.from(message.data).toString('utf8');
const response = JSON.parse(data);
/**
* Create a database log for the message retrieved from PubSub
* This is jsut for testing purposes to see if the message is being received
*/
const databaseLog: HydratedDocument<IDatabaseLog> = new DatabaseLog({
type: 'pubsub-message',
date: new Date(),
level: 'info',
details: {
channel: 'pubsub',
message: 'Message retried from PubSub pull subscription',
status: 'SUCCESS',
response: {
...response,
messageId: message.id,
},
},
});
await databaseLog.save();
Logger.debug(`Received message ${message.id}:`);
Logger.debug(`\tData: ${message.data}`);
Logger.debug(`\tAttributes: ${JSON.stringify(message.attributes)}`);
messageCount += 1;
message.ack();
};
subscription.on('message', messageHandler);
/**
* Create an error handler to handle errors
* @param error
*/
const errorHandler = (error: Error): void => {
Logger.error(`Error: ${error}`);
subscription.removeListener('message', messageHandler);
};
subscription.on('error', errorHandler);
/**
* Set the timeout to 60 seconds to close the subscriptions
*/
setTimeout(() => {
subscription.removeListener('message', messageHandler);
subscription.removeListener('error', errorHandler);
Logger.warn(
`Subscription: ${subscriptionName} closed after ${timeout}s - ${messageCount} message(s) received.`
);
}, timeout * 1000);
return `Subscription ${subscriptionName} listening for messages`;
} catch (error) {
Logger.error(error);
if (error instanceof PubSubPublishError) {
throw error;
} else {
throw new PubSubPublishError(
`Failed to pull message from topic ${subscriptionName} with error: ${error}`,
404
);
}
}
};
export {
publishMessageToPubSubTopic,
listenForPubSubPullSubscription,
PubSubPublishError,
};

View File

@ -0,0 +1,89 @@
import {
Client,
DirectionsResponseData,
Language,
ResponseData,
TravelMode,
} from '@googlemaps/google-maps-services-js';
import Logger from '../../lib/logger';
export interface IGoogleMapsDirections {
origin: string;
destination: string;
}
const getGoogleMapsDirections = async (
origin: string,
destination: string
): Promise<ResponseData | DirectionsResponseData> => {
try {
const client = new Client();
const response = await client.directions({
params: {
origin,
destination,
mode: <TravelMode>'driving',
language: <Language>'it',
key: <string>process.env.GOOGLE_MAPS_API_KEY,
},
});
// if Google Maps API returns OK create an object to use with mongodb
if (response.data.status === 'OK') {
const direction = response.data.routes[0].legs[0];
const distanceObject = {
status: response.data.status,
error_message: response.data.error_message,
distance: {
text: direction.distance.text,
value: direction.distance.value,
},
duration: {
text: direction.duration.text,
value: direction.duration.value,
},
start: {
address: direction.start_address,
location: {
coordinates: [
direction.start_location.lat,
direction.start_location.lng,
],
},
},
end: {
address: direction.end_address,
location: {
coordinates: [
direction.end_location.lat,
direction.end_location.lng,
],
},
},
};
return distanceObject;
}
return response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any | unknown) {
/**
* Google Maps API returns error in different forms
* If we use a throw we can block the execution of the function
* so for now we just return an object containing the error
* to store into mongodb travel schema
* directions returns code: error.response.status
* directions returns error: error.response.data.status
* directions returns error message: error.response.data.error_message
*/
Logger.error(error);
return {
geocoded_waypoints: error.response.data.geocoded_waypoints,
status: error.response.status,
error_message: error.response.data.error_message,
};
}
};
export {getGoogleMapsDirections};

View File

@ -0,0 +1,108 @@
import {getMessaging} from 'firebase-admin/messaging';
import {CustomError} from '../../errors';
export interface IFirebaseMessage {
title: string;
body: string;
}
/**
* Send a single firebase message
* @param message
* @param userFirebaseToken
* @returns
*/
const sendSingleFirebaseMessage = async (
message: IFirebaseMessage,
userFirebaseToken: string
): Promise<object> => {
const {title, body} = message;
const messageObject = {
data: {
title,
body,
},
token: userFirebaseToken,
};
try {
const response = await getMessaging().send(messageObject);
return {message: 'Successfully sent message', response};
} catch (error) {
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
throw error;
}
};
/**
* Send a multicast firebase message
* @param message
* @param usersFirebaseTokens
* @returns
*/
const sendMulticastFirebaseMessage = async (
message: IFirebaseMessage,
usersFirebaseTokens: Array<string>
): Promise<{
status: string;
message: string;
response: object;
failedTokens?: string[];
}> => {
const {title, body} = message;
const messageObject = {
data: {
title,
body,
},
tokens: usersFirebaseTokens,
};
try {
const response = await getMessaging().sendMulticast(messageObject);
if (response.failureCount > 0 && response.successCount > 0) {
const failedTokens: string[] = [];
response.responses.forEach((resp, idx) => {
if (!resp.success) {
failedTokens.push(usersFirebaseTokens[idx]);
}
});
return {
status: 'incomplete',
message: 'Some messages were not sent to users',
response,
failedTokens,
};
} else if (response.successCount === 0) {
return {
status: 'error',
message: 'Failed to send all messages to users',
response,
failedTokens: usersFirebaseTokens,
};
} else {
return {
status: 'success',
message: 'Successfully sent message to all users',
response,
failedTokens: [],
};
}
} catch (error) {
if (error instanceof CustomError) {
throw new CustomError(error.statusCode, error.message);
}
throw error;
}
};
export {sendSingleFirebaseMessage, sendMulticastFirebaseMessage};

View File

@ -0,0 +1,80 @@
import {initClient, ConversationParameter} from 'messagebird';
import Logger from '../../lib/logger';
import DatabaseLog, {
IDatabaseLog,
} from '../../api/v1/database-logs/databaseLog.model';
import {HydratedDocument} from 'mongoose';
const sendWhatsappMessageWithMessagebird = (toNumber: string): void => {
const {
MESSAGEBIRD_ACCESS_KEY,
MESSAGEBIRD_WHATSAPP_CHANNEL_ID,
MESSAGEBIRD_TEMPLATE_NAMESPACE_ID,
MESSAGEBIRD_TEMPLATE_NAME_TEST,
} = process.env;
const messagebird = initClient(<string>MESSAGEBIRD_ACCESS_KEY);
const params: ConversationParameter = {
to: toNumber,
from: <string>MESSAGEBIRD_WHATSAPP_CHANNEL_ID,
type: 'hsm',
reportUrl: 'https://your.report.url',
content: {
hsm: {
namespace: <string>MESSAGEBIRD_TEMPLATE_NAMESPACE_ID,
templateName: <string>MESSAGEBIRD_TEMPLATE_NAME_TEST,
language: {
code: 'en',
policy: 'deterministic',
},
components: [
{
type: 'body',
parameters: [{type: 'text', text: 'Variable 1'}],
},
],
},
},
};
messagebird.conversations.send(params, async (err, response) => {
if (err) {
Logger.error(err);
const databaseLog: HydratedDocument<IDatabaseLog> = new DatabaseLog({
type: 'message',
date: new Date(),
level: 'error',
details: {
channel: 'whatsapp',
message: 'No message was sent',
status: 'ERROR',
response: {...err, recipient: toNumber},
},
});
await databaseLog.save();
} else {
console.log('response', response);
Logger.info(response);
/**
* Save the message to the database using the log model
*/
const databaseLog: HydratedDocument<IDatabaseLog> = new DatabaseLog({
type: 'message',
date: new Date(),
level: 'info',
details: {
channel: 'whatsapp',
message: <string>MESSAGEBIRD_TEMPLATE_NAME_TEST,
status: 'SUCCESS',
response: {...response, recipient: toNumber},
},
});
await databaseLog.save();
}
});
};
export {sendWhatsappMessageWithMessagebird};

View File

@ -0,0 +1,198 @@
import axios from 'axios';
import PdfPrinter from 'pdfmake';
import {Storage} from '@google-cloud/storage';
import slugify from 'slugify';
import {format} from 'util';
import {TDocumentDefinitions} from 'pdfmake/interfaces';
import {IUploadResponse} from '../upload/upload.service';
/**
* Define the storage bucket
*/
const storage = new Storage();
const bucket = storage.bucket(<string>process.env.GOOGLE_STORAGE_BUCKET_NAME);
/**
* Define the interface for the pdf object
*/
export interface IPDFObject {
key: string;
}
const generatePDF = async (
body: IPDFObject,
directory: string
): Promise<IUploadResponse> => {
/**
* Desctructure the body
*/
const {key} = body;
/**
* Define some constants
*/
const TODAY_DATE = new Intl.DateTimeFormat('it-IT').format(new Date());
const COMPANY_NAME = 'Company Name'; // replace with your own company name
const COMPANY_LOGO = `https://storage.googleapis.com/${process.env.GOOGLE_STORAGE_BUCKET_NAME}/company-logo.png`;
const SERVICE_FOLDER = 'express-typescript-api-rest'; // replace with your own service folder name
/**
* Get the logo image from the url
*/
const LOGO_IMAGE_URL = await axios
.get(COMPANY_LOGO, {responseType: 'arraybuffer'})
.then(res => res.data);
/**
* return the array buffer for pdfmake
*/
const LOGO_IMAGE_BASE_64 = `data:image/png;base64,${Buffer.from(
LOGO_IMAGE_URL
).toString('base64')}`;
/**
* Define the fonts
*/
const fonts = {
Courier: {
normal: 'Courier',
bold: 'Courier-Bold',
italics: 'Courier-Oblique',
bolditalics: 'Courier-BoldOblique',
},
Helvetica: {
normal: 'Helvetica',
bold: 'Helvetica-Bold',
italics: 'Helvetica-Oblique',
bolditalics: 'Helvetica-BoldOblique',
},
Times: {
normal: 'Times-Roman',
bold: 'Times-Bold',
italics: 'Times-Italic',
bolditalics: 'Times-BoldItalic',
},
Symbol: {
normal: 'Symbol',
},
ZapfDingbats: {
normal: 'ZapfDingbats',
},
};
// instantiate PDFMake
const printer = new PdfPrinter(fonts);
// set a general font size
const fontSize = 12;
/**
* Define the document definition
*/
const docDefinition: TDocumentDefinitions = {
info: {
title: 'PDF Document',
author: 'Author Name',
subject: 'Subject',
keywords: 'Keywords',
},
header: (currentPage, pageCount, pageSize) => {
return [
{
text: `Header: ${new Intl.DateTimeFormat('it-IT').format(
new Date()
)} - ${key}`,
alignment: currentPage % 2 ? 'right' : 'right',
fontSize: fontSize - 4,
lineHeight: 1.2,
margin: [20, 20, 30, 20],
},
{
canvas: [
{type: 'rect', x: 170, y: 32, w: pageSize.width - 170, h: 40},
],
},
];
},
footer: (currentPage, pageCount, pageSize) => {
// you can apply any logic and return any valid pdfmake element
return [
{
text: 'This is a footer. You can apply any logic and return any valid pdfmake element',
alignment: 'center',
fontSize: fontSize - 6,
lineHeight: 1.2,
margin: [10, 10, 10, 10],
},
{
canvas: [
{type: 'rect', x: 170, y: 32, w: pageSize.width - 170, h: 40},
],
},
];
},
content: [
{
image: LOGO_IMAGE_BASE_64,
width: 150,
},
{
text: `Some text here ${TODAY_DATE}`,
fontSize: fontSize - 2,
lineHeight: 1.3,
margin: [10, 30, 10, 10],
alignment: 'right',
bold: true,
},
],
defaultStyle: {
font: 'Helvetica',
},
};
// This produce a stream already, so we don't need to create a new one
const pdfBuffer = printer.createPdfKitDocument(docDefinition);
pdfBuffer.end();
/**
* Define the file name
*/
const fileName = `FileName_${COMPANY_NAME.replace(/ /g, '_')}.pdf`;
/**
* FINALLY, RETURN THE PROMISE PASSING THE STREAM AND THE FILENAME
*/
return new Promise((resolve, reject) => {
const blob = bucket.file(
`${SERVICE_FOLDER}/${directory}/${slugify(fileName)}`
);
const blobStream = pdfBuffer.pipe(
blob.createWriteStream({
resumable: false,
public: true,
metadata: {
contentType: 'application/pdf',
cacheControl: 'no-store',
},
})
);
blobStream
.on('finish', () => {
const blobName = blob.name;
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
resolve({publicUrl, blobName});
})
.on('error', error => {
reject(error || 'unable to upload file');
});
});
};
export {generatePDF};

View File

@ -0,0 +1,224 @@
import {Storage} from '@google-cloud/storage';
import fs from 'fs';
import slugify from 'slugify';
import stream from 'stream';
import {format} from 'util';
import crypto from 'crypto';
import {CustomError} from '../../errors';
import Logger from '../../lib/logger';
export interface IUploadResponse {
publicUrl: string;
blobName: string;
}
const storage = new Storage();
const bucket = storage.bucket(<string>process.env.GOOGLE_STORAGE_BUCKET_NAME);
/**
* This function create and upload a file to the local file system
* 0. Always pass a buffer like argument otherwise will fail
* 1. Takes a buffer argument
* @param buffer
* @param filename
*/
const streamBufferToLFS = async (
buffer: Buffer,
filename: string
): Promise<void> => {
const file = `${filename}-${Date.now()}.xml`;
fs.writeFile(file, buffer, err => {
if (err) {
console.log(err);
} else {
Logger.debug('The file was saved!');
}
});
};
/**
* This function upload a file directly to gcs without passing buffer.
* 0. To make this work use multer memory storage middleware
* 1. Only instance of a file with buffer will succeed
* 2. Return a public url
* @param file
* @returns
*/
const uploadFileToGCS = async (
file: Express.Multer.File
): Promise<IUploadResponse> => {
const RANDOM_ID = Math.random().toString(36).substring(2, 15); // replace with your own id
const SERVICE_FOLDER = 'express-typescript-api-rest'; // replace with your own service folder name
const DIRECTORY = `uploads/${RANDOM_ID}`; // replace with your own directory name
return new Promise((resolve, reject) => {
const {originalname, buffer, mimetype} = file;
const blob = bucket.file(
`${SERVICE_FOLDER}/${DIRECTORY}/${slugify(originalname)}`
);
const blobStream = blob.createWriteStream({
resumable: false,
public: true,
predefinedAcl: 'publicRead',
metadata: {
contentType: mimetype,
cacheControl: 'no-store',
},
});
blobStream
.on('finish', () => {
const blobName = blob.name;
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
resolve({publicUrl, blobName});
})
.on('error', error => {
reject(error || 'unable to upload file');
})
.end(buffer);
});
};
/**
* This function take a pure buffer and convert to stream
* 0. Always pass a buffer like argument otherwise will fail
* 1. Takes a buffer argument
* 2. Create a stream to store in memory
* 3. Pipe the stream to Google Cloud Storage
* 4. As soon as the file is recreated returns a public url
* @return {Promise<void>}
* @param buffer
*/
const streamBufferToGCS = async (buffer: Buffer): Promise<IUploadResponse> => {
const RANDOM_ID = Math.random().toString(36).substring(2, 15); // replace with your own id
const SERVICE_FOLDER = 'express-typescript-api-rest'; // replace with your own service folder name
const DIRECTORY = `uploads/${RANDOM_ID}`; // replace with your own directory name
const FILE_NAME = 'test.xml'; // replace with your own file name
const dataStream = new stream.PassThrough();
dataStream.push(buffer);
dataStream.push(null);
return new Promise((resolve, reject) => {
const blob = bucket.file(`${SERVICE_FOLDER}/${DIRECTORY}/${FILE_NAME}`);
const blobStream = dataStream.pipe(
blob.createWriteStream({
resumable: false,
public: true,
predefinedAcl: 'publicRead',
metadata: {
cacheControl: 'no-store',
},
})
);
blobStream
.on('finish', () => {
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
resolve({publicUrl, blobName: blob.name});
})
.on('error', error => {
reject(error);
});
});
};
/**
* This function take an object that also contain a buffer
* 0. Always pass an object that contains buffer otherwise will fail
* 1. Takes also a directory like argument
* 2. Create a stream to store in memory
* 3. Pipe the stream to Google Cloud Storage
* 4. As soon as the file is recreated returns a public url
* @return {Promise<void>}
* @param file
* @param {string} directory
*/
const streamFileToGCS = async (
file: Express.Multer.File,
directory: string
): Promise<IUploadResponse> => {
const SERVICE_FOLDER = 'express-typescript-api-rest'; // replace with your own service folder name
// destructuring data file object
const {originalname, buffer, mimetype} = file;
// generate a random uuid to avoid duplicate file name
const uuid = crypto.randomBytes(4).toString('hex');
// generate a file name
const fileName = `${uuid} - ${originalname.replace(/ /g, '_')}`;
// Instantiate a stream to read the file buffer
const dataStream = new stream.PassThrough();
dataStream.push(buffer);
dataStream.push(null);
return new Promise((resolve, reject) => {
const blob = bucket.file(
`${SERVICE_FOLDER}/${directory}/${slugify(fileName || uuid)}`
);
const blobStream = dataStream.pipe(
blob.createWriteStream({
resumable: false,
public: true,
predefinedAcl: 'publicRead',
metadata: {
contentType: mimetype,
cacheControl: 'no-store',
},
})
);
blobStream
.on('finish', () => {
const blobName = blob.name;
const publicUrl = format(
`https://storage.googleapis.com/${bucket.name}/${blob.name}`
);
resolve({publicUrl, blobName});
})
.on('error', error => {
reject(error);
});
});
};
/**
*
* @param blobName
* @returns
*/
const deleteFileFromGCS = async (blobName: string): Promise<void> => {
try {
await bucket.file(blobName).delete();
} catch (e) {
Logger.error(e);
// console.log(e.toString());
if (e instanceof CustomError) {
throw new CustomError(
404,
`Failed to delete file ${blobName}: ${e.message}`
);
} else {
throw new Error(`Failed to delete file ${blobName}`);
}
}
};
export {
streamBufferToLFS,
uploadFileToGCS,
streamBufferToGCS,
streamFileToGCS,
deleteFileFromGCS,
};

View File

@ -0,0 +1,90 @@
import {create} from 'xmlbuilder2';
import stream from 'stream';
import {Storage} from '@google-cloud/storage';
import crypto from 'crypto';
import slugify from 'slugify';
import {IUploadResponse} from '../upload/upload.service';
export interface IXMLObject {
key: string;
}
const storage = new Storage();
const bucket = storage.bucket(<string>process.env.GOOGLE_STORAGE_BUCKET_NAME);
const generateXML = async (body: IXMLObject): Promise<IUploadResponse> => {
const SERVICE_FOLDER = 'express-typescript-api-rest';
const DIRECTORY = 'xml';
const UUID = crypto.randomBytes(4).toString('hex');
const {key} = body;
const doc = create(
{version: '1.0', encoding: 'UTF-8'},
{
// '?': 'xml-stylesheet type="text/xsl" href="https://storage.googleapis.com/your-bucket/assets/xml/stylesheet.xsl"',
'p:MainXmlSubject': {
'@': {
'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'xmlns:p':
'http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2',
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
versione: 'FPR12',
},
Header: {
SubHeader: {
Key: {
Value: 'value',
},
},
},
Body: {
SubBody: {
Key: {
Value: 'value',
},
},
},
},
}
).doc();
const xmlBuffer = doc.end({headless: true, prettyPrint: true});
const dataStreama = new stream.PassThrough();
dataStreama.push(xmlBuffer);
dataStreama.push(null);
const fileName = `IT09568521000_${UUID}_${key}.xml`;
return new Promise((resolve, reject) => {
const blob = bucket.file(
`${SERVICE_FOLDER}/${DIRECTORY}/${slugify(fileName)}.xml`
);
const blobStream = dataStreama.pipe(
blob.createWriteStream({
resumable: false,
public: true,
predefinedAcl: 'publicRead',
metadata: {
cacheControl: 'no-store',
contentType: 'application/xml',
},
})
);
blobStream
.on('finish', () => {
const blobName = blob.name;
const publicUrl = `https://storage.googleapis.com/${bucket.name}/${blob.name}`;
resolve({publicUrl, blobName});
})
.on('error', error => {
reject(error);
});
});
};
export {generateXML};

19
src/tests/index.test.ts Normal file
View File

@ -0,0 +1,19 @@
import mongoose from 'mongoose';
// eslint-disable-next-line node/no-unpublished-import
import request from 'supertest';
import app from '../index';
/**
* Test to see if the server is running
*/
describe(`GET /api/v1/${process.env.SERVICE_NAME}`, () => {
test('should return 200 OK', async () => {
const res = await request(app).get(`/api/v1/${process.env.SERVICE_NAME}`);
expect(res.statusCode).toEqual(200);
});
afterAll(done => {
// Closing the DB connection allows Jest to exit successfully.
mongoose.connection.close();
done();
});
});

View File

@ -0,0 +1,99 @@
import {
formatDateToITLocale,
generateDateRangeArray,
getDaysCountBetweenDates,
getFormattedDate,
getMonthDaysCount,
getMonthsCountBetweenDates,
isDateToday,
} from '../../utils/dates.utils';
describe('Date Utilities Tests Suite', () => {
describe('generateDateRangeArray', () => {
it('should return an empty array when no start or end date is provided', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const result = generateDateRangeArray(null, null);
expect(result).toEqual([]);
});
it('should generate an array of dates between the start and end dates', () => {
const startDate = new Date('2023-02-01');
const endDate = new Date('2023-02-05');
const result = generateDateRangeArray(startDate, endDate);
expect(result.length).toEqual(5);
});
});
describe('getMonthDaysCount', () => {
it('should return the correct number of days in the specified month', () => {
const result = getMonthDaysCount(2, 2023);
expect(result).toEqual(28);
});
});
describe('getDaysCountBetweenDates', () => {
it('should return the correct number of days between two dates', () => {
const startDate = new Date('2023-02-01');
const endDate = new Date('2023-02-05');
const result = getDaysCountBetweenDates(startDate, endDate);
expect(result).toEqual(4);
});
});
describe('getMonthsCountBetweenDates', () => {
it('should return the correct number of months between two dates', () => {
const startDate = new Date('2022-12-01');
const endDate = new Date('2023-02-01');
const result = getMonthsCountBetweenDates(startDate, endDate);
expect(result).toEqual(2);
});
});
describe('formatDateToITLocale', () => {
it('should return a formatted date in Italian locale', () => {
const date = new Date('2023-02-20');
const result = formatDateToITLocale(date);
expect(result).toEqual('20/02/2023');
});
});
describe('isDateToday', () => {
it('should return true when the date is today', () => {
const date = new Date();
const result = isDateToday(date);
expect(result).toEqual(true);
});
it('should return false when the date is not today', () => {
const today = new Date();
const nonTodayDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
const result = isDateToday(nonTodayDate);
expect(result).toEqual(false);
});
});
describe('getFormattedDate', () => {
it('should return an empty string if the input date is not valid', () => {
const invalidDate = new Date('invalid');
const result = getFormattedDate(invalidDate);
expect(result).toEqual('');
});
it('should return a formatted date in the specified format', () => {
const date = new Date('2023-02-20');
const result = getFormattedDate(date, 'dd/MM/yyyy');
expect(result).toEqual('20/02/2023');
});
it('should return a formatted date in the default format if no format is specified', () => {
const date = new Date('2023-02-20');
const result = getFormattedDate(date);
expect(result).toEqual('2023-02-20');
});
});
});

View File

@ -0,0 +1,54 @@
// eslint-disable-next-line node/no-unpublished-import
import request from 'supertest';
import jwt from 'jsonwebtoken';
import express from 'express';
import {ICustomExpressRequest} from '../../middlewares/currentUser.middleware';
import {
generateOTP,
generateCookie,
generateJsonWebToken,
JwtPayload,
} from '../../../src/utils/generators.utils';
// Mock process.env.JWT_KEY with a string value
process.env.JWT_KEY = 'mock_jwt_key';
describe('Generators Utilities Tests Suite', () => {
describe('generateOTP', () => {
it('should generate a 6 digit OTP', () => {
const otp = generateOTP();
expect(otp).toHaveLength(6);
});
});
describe('generateJsonWebToken', () => {
it('should generate a JWT token', () => {
const payload = {id: 1, username: 'user1'};
const token = generateJsonWebToken(payload);
const decoded = jwt.verify(token, process.env.JWT_KEY!) as JwtPayload<
typeof payload
>;
expect(decoded.payload).toEqual(payload);
});
});
describe('generateCookie', () => {
it('should set a cookie with the given name and token', async () => {
const app = express();
const cookieName = 'my-cookie';
const token = 'my-token';
app.get('/set-cookie', (req, res) => {
generateCookie(cookieName, token, req as ICustomExpressRequest, res);
res.status(200).send('Cookie set');
});
const response = await request(app).get('/set-cookie');
expect(response.status).toBe(200);
expect(response.header['set-cookie']).toBeDefined();
expect(response.header['set-cookie'][0]).toContain(cookieName);
expect(response.header['set-cookie'][0]).toContain(token);
});
});
});

View File

@ -0,0 +1,43 @@
import {cleanObject} from '../../utils/objects.utils';
describe('cleanObject', () => {
it('should remove null and undefined values from an object and its nested objects', () => {
const input = {
a: 1,
b: null,
c: {
d: 'hello',
e: null,
f: {
g: 2,
h: undefined,
},
},
d: [
{
a: 1,
b: [null, undefined, 1, 2, 3],
},
{
b: null,
},
],
};
const expectedOutput = {
a: 1,
c: {
d: 'hello',
f: {
g: 2,
},
},
d: [
{
a: 1,
b: [1, 2, 3],
},
],
};
expect(cleanObject(input)).toEqual(expectedOutput);
});
});

5
src/types/xss-clean.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module 'xss-clean' {
const value: Function;
export default value;
}

View File

@ -0,0 +1,42 @@
import {Response} from 'express';
import {IUserMethods} from '../api/v1/user/user.model';
import {ICustomExpressRequest} from '../middlewares/currentUser.middleware';
/**
*
* This function returns a json with user data,
* token and the status and set a cookie with
* the name jwt. We use this in the response
* of login or signup
* @param user:
* @param statusCode
* @param req
* @param res
*/
const createCookieFromToken = (
user: IUserMethods,
statusCode: number,
req: ICustomExpressRequest,
res: Response
) => {
const token = user.generateVerificationToken();
const cookieOptions = {
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
};
res.cookie('jwt', token, cookieOptions);
res.status(statusCode).json({
status: 'success',
token,
token_expires: cookieOptions.expires,
data: {
user,
},
});
};
export default createCookieFromToken;

132
src/utils/dates.utils.ts Normal file
View File

@ -0,0 +1,132 @@
/**
* Generates an array of dates between the start and end dates
* @param startDate
* @param endDate
* @returns
*/
const generateDateRangeArray = (startDate: Date, endDate: Date) => {
let dates: Date[] = [];
if (!startDate || !endDate) {
return dates;
}
// to avoid modifying the original date
const currentDate = new Date(startDate);
while (currentDate < new Date(endDate)) {
dates = [...dates, new Date(currentDate)];
currentDate.setDate(currentDate.getDate() + 1);
}
dates = [...dates, new Date(endDate)];
return dates;
};
/**
* Returns the number of days in a month
* @param month
* @param year
* @returns
*/
const getMonthDaysCount = (month: number, year: number): number => {
return new Date(year, month, 0).getDate();
};
/**
* Returns the number of days between two dates
* @param startDateObj
* @param endDateObj
* @returns
*/
const getDaysCountBetweenDates = (startDateObj: Date, endDateObj: Date) => {
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const startDate = new Date(startDateObj).setHours(0, 0, 0, 0);
const endDate = new Date(endDateObj).setHours(0, 0, 0, 0);
const timeDiff = Math.abs(startDate - endDate);
const daysDiff = Math.ceil(timeDiff / MILLISECONDS_PER_DAY);
return daysDiff;
};
/**
* Returns the number of months between two dates
* @param date1
* @param date2
* @returns
*/
const getMonthsCountBetweenDates = (startDateObj: Date, endDateObj: Date) => {
const startDate = new Date(startDateObj);
const endDate = new Date(endDateObj);
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const monthsDiff = (endYear - startYear) * 12 + (endMonth - startMonth);
return Math.abs(monthsDiff);
};
/**
* Returns the number of months between two dates
* @param date
* @returns
*/
const formatDateToITLocale = (date: Date) => {
return new Intl.DateTimeFormat('it-IT', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
/**
* Checks if a date is today
* @param date
* @returns
*/
const isDateToday = (date: Date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
const getFormattedDate = (date: Date, format = 'yyyy-MM-dd') => {
if (isNaN(date.getTime())) {
return '';
}
const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const formattedDate = dateFormatter.format(date);
const formattedDateParts = formattedDate.split('/');
const year = formattedDateParts[2];
const month = formattedDateParts[0];
const day = formattedDateParts[1];
return format
.replace(/yyyy/g, year)
.replace(/MM/g, month)
.replace(/dd/g, day);
};
export {
generateDateRangeArray,
getMonthDaysCount,
getDaysCountBetweenDates,
getMonthsCountBetweenDates,
formatDateToITLocale,
isDateToday,
getFormattedDate,
};

View File

@ -0,0 +1,66 @@
import crypto from 'crypto';
import {ICustomExpressRequest} from '../middlewares/currentUser.middleware';
import {Response} from 'express';
import jwt from 'jsonwebtoken';
export interface JwtPayload<T> {
[key: string]: T;
}
/**
* Generate a json web token
* @param payload
* @returns
*/
const generateJsonWebToken = <T>(payload: JwtPayload<T>): string => {
const jwtKey = process.env.JWT_KEY;
if (!jwtKey) {
throw new Error('Missing JWT');
}
return jwt.sign({payload}, jwtKey, {
expiresIn: '10d',
// algorithm: 'RS256',
});
};
/**
* Generate a cookie with a token
* @param cookieName
* @param token
* @param req
* @param res
*/
const generateCookie = (
cookieName: string,
token: string,
req: ICustomExpressRequest,
res: Response
) => {
const cookieOptions = {
expires: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
httpOnly: true,
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
};
res.cookie(cookieName, token, cookieOptions);
};
/**
* Generate a random OTP
* @returns
*/
const generateOTP = (): string => {
const chars = '0123456789';
let otp = '';
while (otp.length < 6) {
const randomBytes = crypto.randomBytes(4);
const randomIndex = randomBytes.readUInt32BE(0) % chars.length;
otp += chars.charAt(randomIndex);
}
return otp;
};
export {generateOTP, generateCookie, generateJsonWebToken};

View File

@ -0,0 +1,11 @@
import cleanDeep from 'clean-deep';
interface IObjectWithNulls {
[key: string]: unknown | null | IObjectWithNulls;
}
const cleanObject = (obj: IObjectWithNulls): IObjectWithNulls => {
return cleanDeep(obj);
};
export {cleanObject};

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"noImplicitReturns": false,
"paths": {
"*": ["./node_modules/*", "./src/types/*"]
}
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "build", "docs", "**/*.test.ts"]
}

11
typedoc.json Normal file
View File

@ -0,0 +1,11 @@
{
// Comments are supported, like tsconfig.json
"entryPoints": [
"src/index.ts",
"src/api/v1/app/app.controller.ts",
"src/middlewares/currentUser.middleware.ts"
],
"exclude": ["**/node_modules/**", "**/*.spec.ts", "**/*.test.ts", "dist"],
"out": "docs"
}