├── .editorconfig ├── .eslintrc.js ├── .github ├── pull_request_template.md └── workflows │ ├── deploy-google.yml │ └── tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .vscode ├── launch.json └── tasks.json ├── Dockerfile ├── README.md ├── app.yaml ├── docker-compose.yml ├── jest.config.js ├── package.json ├── prettier.config.js ├── src ├── app.ts ├── index.ts ├── routes │ ├── index.ts │ ├── main │ │ ├── controller.ts │ │ └── index.ts │ ├── profile │ │ ├── controller.ts │ │ └── index.ts │ ├── shop │ │ ├── controller.ts │ │ ├── index.ts │ │ └── paypal.ts │ └── swagger.yaml └── shared │ ├── auth │ ├── auth0.configure.ts │ ├── auth0.ts │ ├── firebase.ts │ └── index.ts │ ├── config │ ├── app.json │ ├── auth0.json │ └── index.ts │ ├── db │ ├── check.ts │ ├── index.ts │ ├── migrator.ts │ └── template.ts │ ├── errorHandler.ts │ ├── firebase.ts │ ├── logger.ts │ ├── model-api │ ├── controller.ts │ ├── routes.ts │ └── swagger.ts │ ├── secrets.ts │ ├── server.ts │ ├── settings.ts │ ├── socket │ ├── handlers │ │ └── index.ts │ └── index.ts │ ├── trace.ts │ ├── types │ ├── auth.ts │ ├── blab.ts │ ├── cart.ts │ ├── category.ts │ ├── chapter.ts │ ├── drawing.ts │ ├── index.ts │ ├── item.ts │ ├── models │ │ ├── cart.ts │ │ ├── category.ts │ │ ├── drawing.ts │ │ ├── index.ts │ │ ├── item.ts │ │ ├── order.ts │ │ ├── product.ts │ │ ├── setting.ts │ │ ├── subscription.ts │ │ ├── user.ts │ │ └── wallet.ts │ ├── order.ts │ ├── page.ts │ ├── product.ts │ ├── setting.ts │ ├── subscription.ts │ ├── user copy.ts │ ├── user.ts │ └── wallet.ts │ └── util.ts ├── tests ├── db │ ├── migrations.test.ts │ └── models.test.ts ├── helpers.ts ├── server.test.ts └── tsconfig.json ├── tools ├── createEnvironmentHash.js ├── env.js └── paths.js ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | insert_final_newline = true 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'eslint-plugin-import'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:import/typescript', 9 | ], 10 | ignorePatterns: ['dist', 'build', 'node_modules'], 11 | rules: { 12 | '@typescript-eslint/ban-types': 'off', 13 | '@typescript-eslint/no-explicit-any': 'off', 14 | '@typescript-eslint/no-unused-vars': 'error', 15 | 'no-console': 'warn', 16 | 'import/no-named-as-default': 'off', 17 | }, 18 | settings: { 19 | 'import/resolver': { 20 | node: true, 21 | typescript: { 22 | project: './tsconfig.json', 23 | }, 24 | }, 25 | }, 26 | overrides: [ 27 | { 28 | files: ['*.js'], 29 | rules: { 30 | 'no-undef': 'off', 31 | '@typescript-eslint/no-var-requires': 'off', 32 | }, 33 | }, 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/deploy-google.yml: -------------------------------------------------------------------------------- 1 | name: 'Deploy: Google Cloud' 2 | on: 3 | push: 4 | branches: ['master'] 5 | paths: 6 | - 'workspaces/**' 7 | - '.github/workflows/**' 8 | env: 9 | VM_NAME: 'drawserver2' 10 | PROJECT: 'mstream-368503' 11 | PROJECT_NO: 364055912546 12 | ZONE: 'us-central1-a' 13 | REGISTRY: 'gcr.io' 14 | IMAGE_PATH: 'gcr.io/mstream-368503' 15 | # REGISTRY: 'us-central1-docker.pkg.dev' 16 | # IMAGE_PATH: 'us-central1-docker.pkg.dev/mstream-368503/goodvibes' 17 | 18 | jobs: 19 | changes: 20 | runs-on: 'ubuntu-latest' 21 | outputs: 22 | deploy: ${{ steps.filter.outputs.deploy }} 23 | test: ${{ steps.filter.outputs.test }} 24 | client: ${{ steps.filter.outputs.client }} 25 | server: ${{ steps.filter.outputs.server }} 26 | killswitch: ${{ steps.filter.outputs.killswitch }} 27 | steps: 28 | - uses: 'actions/checkout@v3' 29 | - uses: dorny/paths-filter@v2 30 | id: filter 31 | with: 32 | filters: | 33 | deploy: 34 | - '.github/workflows/deploy-google.yml' 35 | test: 36 | - 'src/**' 37 | - 'tests/**' 38 | server: 39 | - 'src/**' 40 | 41 | tests: 42 | needs: [changes] 43 | runs-on: 'ubuntu-latest' 44 | steps: 45 | - uses: actions/checkout@v3 46 | if: ${{ needs.changes.outputs.test == 'true' }} 47 | 48 | - name: Install 49 | if: ${{ needs.changes.outputs.test == 'true' }} 50 | run: yarn install --immutable 51 | 52 | - name: Server Tests 53 | if: ${{ needs.changes.outputs.server == 'true' }} 54 | run: yarn test 55 | 56 | server_deploy: 57 | needs: [changes, tests] 58 | if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.deploy == 'true' }} 59 | runs-on: 'ubuntu-latest' 60 | permissions: 61 | contents: 'read' 62 | id-token: 'write' 63 | steps: 64 | - uses: 'actions/checkout@v3' 65 | 66 | - name: 'auth' 67 | uses: 'google-github-actions/auth@v1' 68 | with: 69 | workload_identity_provider: 'projects/364055912546/locations/global/workloadIdentityPools/default-pool/providers/github-provider' 70 | service_account: 'github@mstream-368503.iam.gserviceaccount.com' 71 | 72 | - id: 'secrets' 73 | uses: 'google-github-actions/get-secretmanager-secrets@v1' 74 | with: 75 | secrets: |- 76 | DB_URL:364055912546/DB_URL 77 | 78 | - name: 'tag' 79 | run: echo "TAG=$IMAGE_PATH/$VM_NAME.${{ github.ref_name }}:latest" >> $GITHUB_ENV 80 | 81 | - name: Set up Cloud SDK 82 | uses: google-github-actions/setup-gcloud@v1 83 | 84 | - name: 'vmExists' 85 | run: | 86 | # check if it exists 87 | gcloud compute instances describe $VM_NAME --zone $ZONE > /dev/null 2>&1 || export NOT_FOUND=1 88 | if [ $NOT_FOUND -eq 1 ]; then 89 | echo "Not Found" 90 | echo "VM_EXISTS=0" >> $GITHUB_ENV 91 | else 92 | echo "Found" 93 | echo "VM_EXISTS=1" >> $GITHUB_ENV 94 | fi 95 | 96 | - name: 'install' 97 | run: yarn install --immutable 98 | 99 | - name: 'build' 100 | run: | 101 | yarn build 102 | cp app.yaml dist/app.yaml 103 | 104 | - name: '.env' 105 | run: | 106 | echo "DB_URL=$DB_URL" >> dist/.env 107 | echo "SSL_CERT=$SSL_CERT" >> dist/.env 108 | echo "SSL_KEY=$SSL_KEY" >> dist/.env 109 | shell: bash 110 | env: 111 | DB_URL: ${{ steps.secrets.outputs.DB_URL }} 112 | SSL_CERT: ${{ steps.secrets.outputs.SSL_CERT }} 113 | SSL_KEY: ${{ steps.secrets.outputs.SSL_KEY }} 114 | 115 | - name: 'docker.auth' 116 | run: |- 117 | gcloud --quiet auth configure-docker $REGISTRY 118 | 119 | - name: 'docker.push.artifactRegistry' 120 | run: | 121 | docker build -t $TAG . 122 | docker push $TAG 123 | 124 | - name: 'vm.create' 125 | # if: ${{ env.VM_EXISTS == 0 }} 126 | if: ${{ false }} 127 | run: | 128 | echo "VM does not exist, creating with container" 129 | gcloud compute instances create-with-container $VM_NAME \ 130 | --zone=$ZONE \ 131 | --container-image=$TAG \ 132 | --machine-type=e2-micro \ 133 | --tags=http-server,https-server 134 | 135 | - name: 'vm.existing.redeploy' 136 | # if: ${{ env.VM_EXISTS == 1 }} 137 | if: ${{ false }} 138 | run: | 139 | gcloud compute instances update-container $VM_NAME \ 140 | --zone $ZONE \ 141 | --container-image $TAG 142 | 143 | - name: appengine 144 | if: ${{ false }} 145 | uses: 'google-github-actions/deploy-appengine@v1' 146 | with: 147 | working_directory: 'dist' 148 | 149 | - name: cloudrun 150 | if: ${{ false }} 151 | uses: 'google-github-actions/deploy-cloudrun@v1' 152 | with: 153 | service: 'server' 154 | source: '.' 155 | secrets: | 156 | DB_URL=DB_URL:latest 157 | 158 | - name: Get GKE Credentials 159 | uses: 'google-github-actions/get-gke-credentials@v1.0.1' 160 | if: ${{ false }} 161 | with: 162 | cluster_name: ${{ env.VM_NAME }} 163 | location: ${{ env.ZONE }} 164 | 165 | - name: kustomize 166 | if: ${{ false }} 167 | run: |- 168 | curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64 169 | chmod u+x ./kustomize 170 | 171 | - name: gke 172 | if: ${{ false }} 173 | run: |- 174 | ./kustomize edit set image $TAG 175 | ./kustomize build . | kubectl apply -f - 176 | kubectl rollout status deployment/$VM_NAME 177 | kubectl get services -o wide 178 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: '16' 19 | cache: 'yarn' 20 | - run: yarn 21 | - run: docker compose up --wait 22 | - run: yarn build 23 | - run: yarn test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | /**/node_modules 4 | /**/.pnp 5 | /**/.pnp.js 6 | /**/coverage 7 | /**/dist 8 | /**/build 9 | 10 | 11 | # misc 12 | .DS_Store 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | *.log 23 | *.tsbuildinfo 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn precommit 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node-terminal", 9 | "name": "dev", 10 | "command": "yarn dev", 11 | "request": "launch", 12 | "cwd": "${workspaceRoot}" 13 | }, 14 | { 15 | "command": "yarn jest ${fileBasename} --watch --runInBand --bail", 16 | "name": "jest $file", 17 | "request": "launch", 18 | "type": "node-terminal", 19 | "preLaunchTask": "docker.start" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "docker", 7 | "group": "build", 8 | "label": "docker.start", 9 | "detail": "yarn docker", 10 | "isBackground": true, 11 | "problemMatcher": [ 12 | { 13 | "pattern": [ 14 | { 15 | "regexp": ".", 16 | "file": 1, 17 | "location": 2, 18 | "message": 3 19 | } 20 | ], 21 | "background": { 22 | "activeOnStart": true, 23 | "beginsPattern": ".", 24 | "endsPattern": "." 25 | } 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | WORKDIR /usr/src 3 | ADD ./dist . 4 | RUN yarn install --production 5 | CMD ["node", "index.js"] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automated TypeScript Express Backend 2 | 3 | [![Demo](https://img.shields.io/badge/Click%20for%20Demo-GCP.svg)](https://api.drawspace.app/docs) 4 | 5 | [![Tests](https://github.com/ruyd/automated-express-backend/actions/workflows/tests.yml/badge.svg)](https://github.com/ruyd/automated-express-backend/actions/workflows/tests.yml) 6 | 7 | NodeJS Express Starter for backends and microservices 8 | 9 | Easier than GraphQL, customizable and concise, Model-API generic functions for sequelize 10 | 11 | Automate the boilerplate stuff 12 | 13 | [Click to see sample app using it](https://github.com/ruyd/fullstack-monorepo) 14 | 15 | ## Developer Experience 16 | 17 | - Sequelize 18 | - Auto CRUD API Routes for Models 19 | - Auto SwaggerUI Admin 20 | - Non-invasive, allows regular/custom backend work 21 | - JWT Security RS256 22 | - Auth0 Automatic Configuration (even Clients) 23 | - Database Migrations with umzug 24 | - Auto Tests with jest and Docker 25 | - VSCode launchers, debugging server and tests 26 | - Webpack with Hot Reload and Cache 27 | - Git Pre-Commit Hook that run tests and block bad commits 28 | 29 | 30 | ## QuickStart 31 | - if docker is available go with: `yarn start` 32 | - Modify setup/db.json if not and `yarn dev` 33 | 34 | ### Auth0 35 | 36 | - For Auth0 Automated Setup, copy sample.env to workspaces/server/.env and populate with: 37 | - [Create auth0 account](https://auth0.com/signup) 38 | - Go to Dashboard > Applications > API Explorer Application > Settings and copy settins into .env: 39 | - AUTH_TENANT=`tenant` domain without `tenant`.auth0.com 40 | - AUTH_EXPLORER_ID=`Client ID` 41 | - AUTH_EXPLORER_SECRET=`Client Secret` 42 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs18 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: postgres:14.5 4 | ports: 5 | - 5432:5432 6 | environment: 7 | POSTGRES_USER: postgres 8 | POSTGRES_PASSWORD: postgrespass 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest') 2 | const { compilerOptions } = require('./tsconfig.json') 3 | const paths = compilerOptions.paths || {} 4 | const rootPath = compilerOptions.baseUrl || '.' 5 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 6 | module.exports = { 7 | preset: 'ts-jest', 8 | displayName: 'server', 9 | testEnvironment: 'node', 10 | modulePathIgnorePatterns: ['/build/', '/dist/'], 11 | roots: [''], 12 | testMatch: ['/tests/**/*.test.ts'], 13 | modulePaths: [rootPath], 14 | moduleNameMapper: pathsToModuleNameMapper(paths, { prefix: '/' }), 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.5", 4 | "private": true, 5 | "description": "Automated Express Backend", 6 | "main": "index.js", 7 | "scripts": { 8 | "build": "yarn tsc --build && NODE_ENV=production yarn webpack", 9 | "start": "node index.js", 10 | "mon": "NODE_ENV=development yarn nodemon -r tsconfig-paths/register -q src/index.ts", 11 | "watch": "NODE_ENV=development yarn webpack watch", 12 | "dev": "docker compose up --wait && yarn tsc --build && yarn concurrently \"yarn watch\" \"yarn mon\"", 13 | "test": "yarn build && docker compose up --wait && NODE_ENV=test yarn jest --runInBand", 14 | "migrator": "yarn ts-node src/shared/db/migrator.ts", 15 | "clean": "rm -rf node_modules dist", 16 | "prod": "docker compose up --wait && yarn tsc --build && yarn concurrently \"NODE_ENV=production yarn nodemon -r tsconfig-paths/register -q src/index.ts\" \"NODE_ENV=production yarn webpack watch\"", 17 | "cloud": "yarn build && NODE_ENV=production gcloud beta code dev --secrets=DB_URL=DB_URL:latest --allow-secret-manager", 18 | "docker": "docker compose up --wait", 19 | "precommit": "yarn test" 20 | }, 21 | "dependencies": { 22 | "@google-cloud/secret-manager": "^4.2.0", 23 | "auth0": "^3.0.1", 24 | "axios": "^1.4.0", 25 | "bufferutil": "^4.0.7", 26 | "cors": "^2.8.5", 27 | "dotenv": "^16.0.3", 28 | "express": "^4.18.2", 29 | "express-async-errors": "^3.1.1", 30 | "express-basic-auth": "^1.2.1", 31 | "express-jwt": "^8.2.1", 32 | "firebase-admin": "^11.9.0", 33 | "http-status": "^1.5.2", 34 | "jsonwebtoken": "^9.0.0", 35 | "jwks-rsa": "^3.0.0", 36 | "node-cache": "^5.1.2", 37 | "openapi-types": "^12.0.2", 38 | "pg": "^8.7.3", 39 | "sequelize": "^6.32.0", 40 | "socket.io": "^4.5.4", 41 | "swagger-jsdoc": "6.2.8", 42 | "swagger-ui-express": "^4.4.0", 43 | "umzug": "^3.2.1", 44 | "utf-8-validate": "^6.0.3", 45 | "uuid": "^9.0.0", 46 | "winston": "^3.8.2" 47 | }, 48 | "devDependencies": { 49 | "@jest/types": "^29.3.1", 50 | "@types/cors": "^2.8.12", 51 | "@types/express": "^4.17.13", 52 | "@types/express-unless": "^2.0.1", 53 | "@types/jest": "^29.2.4", 54 | "@types/jsonwebtoken": "^9.0.0", 55 | "@types/node": "^20.2.5", 56 | "@types/sequelize": "^4.28.14", 57 | "@types/supertest": "^2.0.12", 58 | "@types/swagger-jsdoc": "^6", 59 | "@types/swagger-ui-express": "^4.1.3", 60 | "@types/uuid": "^9.0.1", 61 | "@types/webpack-node-externals": "^3.0.0", 62 | "@typescript-eslint/eslint-plugin": "^5.49.0", 63 | "@typescript-eslint/parser": "^5.49.0", 64 | "concurrently": "^8.1.0", 65 | "eslint": "^8.19.0", 66 | "eslint-plugin-import": "^2.27.5", 67 | "fork-ts-checker-webpack-plugin": "^8.0.0", 68 | "generate-package-json-webpack-plugin": "^2.6.0", 69 | "jest": "^29.5.0", 70 | "node-polyfill-webpack-plugin": "^2.0.1", 71 | "nodemon": "^2.0.16", 72 | "supertest": "^6.3.3", 73 | "swagger-jsdoc-webpack-plugin": "^2.1.0", 74 | "ts-jest": "^29.1.0", 75 | "ts-loader": "^9.4.1", 76 | "ts-node": "^10.9.1", 77 | "tsconfig-paths": "^4.1.0", 78 | "tsconfig-paths-webpack-plugin": "^4.0.0", 79 | "typescript": "^5.1.3", 80 | "webpack": "^5.75.0", 81 | "webpack-cli": "^5.1.3", 82 | "webpack-node-externals": "^3.0.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | arrowParens: 'avoid', 6 | semi: false, 7 | tabWidth: 2, 8 | }; 9 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { config } from './shared/config' 2 | import express from 'express' 3 | import bodyParser from 'body-parser' 4 | import swaggerUi from 'swagger-ui-express' 5 | import { prepareSwagger } from './shared/model-api/swagger' 6 | import { registerModelApiRoutes } from './shared/model-api/routes' 7 | import { errorHandler } from './shared/errorHandler' 8 | import cors from 'cors' 9 | import api from './routes' 10 | import { activateAxiosTrace, endpointTracingMiddleware, printRouteSummary } from './shared/trace' 11 | import { Connection } from './shared/db' 12 | import { checkDatabase } from './shared/db/check' 13 | import { modelAuthMiddleware } from './shared/auth' 14 | import { homepage } from './shared/server' 15 | 16 | export interface BackendApp extends express.Express { 17 | onStartupCompletePromise: Promise 18 | } 19 | 20 | export interface BackendOptions { 21 | checks?: boolean 22 | trace?: boolean 23 | } 24 | 25 | export function createBackendApp({ checks, trace }: BackendOptions = { checks: true }): BackendApp { 26 | const app = express() as BackendApp 27 | 28 | if (trace !== undefined) { 29 | config.trace = trace 30 | config.db.trace = trace 31 | } 32 | 33 | if (!config.production && config.trace) { 34 | activateAxiosTrace() 35 | } 36 | 37 | // Startup 38 | Connection.init() 39 | const promises = [checks ? checkDatabase() : Promise.resolve(true)] 40 | 41 | // Add Middlewares 42 | app.use(cors()) 43 | app.use(express.json({ limit: config.jsonLimit })) 44 | app.use( 45 | bodyParser.urlencoded({ 46 | extended: true, 47 | }), 48 | ) 49 | app.use(endpointTracingMiddleware) 50 | app.use(modelAuthMiddleware) 51 | 52 | // Add Routes 53 | registerModelApiRoutes(Connection.entities, api) 54 | app.use(api) 55 | 56 | const swaggerDoc = prepareSwagger(app, Connection.entities) 57 | app.use( 58 | config.swaggerSetup.basePath, 59 | swaggerUi.serve, 60 | swaggerUi.setup(swaggerDoc, { 61 | customSiteTitle: config.swaggerSetup.info?.title, 62 | swaggerOptions: { 63 | persistAuthorization: true, 64 | }, 65 | }), 66 | ) 67 | 68 | app.onStartupCompletePromise = Promise.all(promises) 69 | 70 | printRouteSummary(app) 71 | 72 | app.get('/', homepage) 73 | 74 | app.use(errorHandler) 75 | 76 | return app 77 | } 78 | 79 | export default createBackendApp 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import config, { canStart } from './shared/config' 2 | import logger from './shared/logger' 3 | import createBackendApp from './app' 4 | import { registerSocket } from './shared/socket' 5 | import { createServerService } from './shared/server' 6 | ;(() => { 7 | if (!canStart()) { 8 | const m = 'No PORT specified: Shutting down - Environment variables undefined' 9 | logger.error(m) 10 | throw new Error(m) 11 | } 12 | 13 | const app = createBackendApp() 14 | const url = config.backendBaseUrl + config.swaggerSetup.basePath 15 | const title = config.swaggerSetup.info?.title 16 | 17 | // Start server 18 | const server = createServerService(app) 19 | registerSocket(server) 20 | 21 | server.listen(config.port, () => 22 | logger.info( 23 | `**************************************************************************\n\ 24 | ⚡️[${title}]: Server is running with SwaggerUI Admin at ${url}\n\ 25 | **************************************************************************`, 26 | ), 27 | ) 28 | return server 29 | })() 30 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import main from './main' 3 | import profile from './profile' 4 | import shop from './shop' 5 | 6 | const router = express.Router() 7 | router.use(main) 8 | router.use(profile) 9 | router.use(shop) 10 | export default router 11 | -------------------------------------------------------------------------------- /src/routes/main/controller.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import sequelize from 'sequelize' 3 | import logger from '../../shared/logger' 4 | import { list } from '../../shared/model-api/controller' 5 | import { DrawingModel, EnrichedRequest, SettingModel, UserModel } from '../../shared/types' 6 | import { v4 as uuid } from 'uuid' 7 | import { createToken } from '../../shared/auth' 8 | import { getClientSettings } from '../../shared/settings' 9 | import { SystemSettings } from '../../shared/types' 10 | 11 | export async function start(req: express.Request, res: express.Response) { 12 | logger.info(`Database Initialization by: ${req.body.email}`) 13 | logger.info('Creating internal settings...') 14 | let defaultSetting = (await SettingModel.findOne({ where: { name: 'internal' } }))?.get() 15 | if (defaultSetting) { 16 | res.status(500) 17 | res.json({ ok: false, error: 'Database already initialized' }) 18 | return 19 | } 20 | let error: Error | null = null 21 | try { 22 | defaultSetting = ( 23 | await SettingModel.create({ 24 | name: 'internal', 25 | data: { 26 | startAdminEmail: req.body.email, 27 | }, 28 | }) 29 | ).get() 30 | } catch (e) { 31 | logger.error(e) 32 | error = e as Error 33 | } 34 | if (!defaultSetting) { 35 | res.status(500) 36 | res.json({ ok: false, error: error?.message }) 37 | return 38 | } 39 | 40 | logger.info('Creating system settings...') 41 | const systemSetting = ( 42 | await SettingModel.create({ 43 | name: 'system', 44 | data: { 45 | disable: true, 46 | } as SystemSettings, 47 | }) 48 | )?.get() 49 | if (!systemSetting) { 50 | res.status(500) 51 | res.json({ ok: false, error: 'Failed to create system setting' }) 52 | return 53 | } 54 | 55 | let user = (await UserModel.findOne({ where: { email: req.body.email } }))?.get() 56 | if (!user) { 57 | user = ( 58 | await UserModel.create({ 59 | userId: uuid(), 60 | email: req.body.email, 61 | firstName: 'Admin', 62 | }) 63 | ).get() 64 | } 65 | 66 | const token = createToken({ 67 | ...user, 68 | roles: ['admin'], 69 | }) 70 | 71 | res.json({ ok: true, token, user }) 72 | } 73 | 74 | export async function gallery(req: express.Request, res: express.Response) { 75 | const conditional = req.params.userId ? { userId: req.params.userId } : {} 76 | const items = await list(DrawingModel, { 77 | where: { 78 | ...conditional, 79 | private: { 80 | [sequelize.Op.not]: true, 81 | }, 82 | }, 83 | }) 84 | res.json(items) 85 | } 86 | 87 | export async function sendClientConfigSettings(req?: express.Request, res?: express.Response) { 88 | const user = (req as EnrichedRequest).auth 89 | const isAdmin = user?.roles?.includes('admin') 90 | const payload = await getClientSettings(isAdmin) 91 | res?.json(payload) 92 | return payload 93 | } 94 | -------------------------------------------------------------------------------- /src/routes/main/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { gallery, sendClientConfigSettings, start } from './controller' 3 | 4 | const router = express.Router() 5 | 6 | router.get(['/gallery', '/gallery/:userId'], gallery) 7 | 8 | router.get('/config', sendClientConfigSettings) 9 | 10 | router.post('/start', start) 11 | 12 | export default router 13 | -------------------------------------------------------------------------------- /src/routes/profile/controller.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { 3 | createToken, 4 | authProviderLogin, 5 | authProviderRegister, 6 | authProviderChangePassword, 7 | lazyLoadManagementToken, 8 | authProviderPatch, 9 | decodeToken, 10 | getAuthSettingsAsync, 11 | } from '../../shared/auth' 12 | import { createOrUpdate } from '../../shared/model-api/controller' 13 | import { UserModel } from '../../shared/types/models/user' 14 | import { AppAccessToken, IdentityToken } from '../../shared/types' 15 | import { v4 as uuid } from 'uuid' 16 | import { decode } from 'jsonwebtoken' 17 | import logger from '../../shared/logger' 18 | import { EnrichedRequest } from '../../shared/types' 19 | import { firebaseCreateToken } from 'src/shared/auth/firebase' 20 | import { getPictureMock } from 'src/shared/util' 21 | 22 | export async function register(req: express.Request, res: express.Response) { 23 | const payload = req.body 24 | if (!payload) { 25 | throw new Error('Missing payload') 26 | } 27 | const { enableRegistration, startAdminEmail } = await getAuthSettingsAsync() 28 | const isStartAdmin = payload.email === startAdminEmail 29 | 30 | if (!enableRegistration) { 31 | throw new Error('Registration is disabled') 32 | } 33 | 34 | const existing = await UserModel.findOne({ where: { email: payload.email } }) 35 | if (existing) { 36 | throw new Error('Email already registered, try Sign-in') 37 | } 38 | const userId = uuid() 39 | setPictureIfEmpty(payload) 40 | const providerData = { ...payload, uid: userId } 41 | const providerResult = await authProviderRegister(providerData) 42 | if (providerResult.error) { 43 | throw new Error(providerResult.error_description) 44 | } 45 | 46 | payload.userId = userId 47 | payload.roles = isStartAdmin ? ['admin'] : [] 48 | const user = await createOrUpdate(UserModel, payload) 49 | const token = await firebaseCreateToken(user.userId, { 50 | roles: user.roles || [], 51 | }) 52 | 53 | res.json({ token, user }) 54 | } 55 | 56 | /** 57 | * Login count 58 | * allow offline mode 59 | */ 60 | export async function login(req: express.Request, res: express.Response) { 61 | const { email: bodyEmail, password, idToken } = req.body 62 | if (!password && !idToken) { 63 | throw new Error('Missing inputs invalid login request') 64 | } 65 | const tokenEmail = decodeToken(idToken)?.email 66 | const email = bodyEmail || tokenEmail 67 | const { startAdminEmail, isDevelopment, isNone } = await getAuthSettingsAsync() 68 | const isStartAdmin = email === startAdminEmail 69 | 70 | let user = ( 71 | await UserModel.findOne({ 72 | where: { email }, 73 | }) 74 | )?.get() 75 | 76 | if ((isDevelopment && user) || (isNone && isStartAdmin)) { 77 | logger.warn('Mocking login without pass for: ' + email) 78 | res.json({ 79 | token: createToken({ 80 | userId: user?.userId, 81 | roles: isStartAdmin ? ['admin'] : [], 82 | }), 83 | user, 84 | }) 85 | return 86 | } 87 | 88 | if (isNone) { 89 | throw new Error('User logins have been disabled - Please contact administrator') 90 | } 91 | 92 | const response = await authProviderLogin({ email, password, idToken }) 93 | if (response.error) { 94 | throw new Error(response.error_description) 95 | } 96 | 97 | if (!user) { 98 | const decoded = decode(response.access_token as string) as AppAccessToken 99 | user = await createOrUpdate(UserModel, { email, userId: decoded.uid }) 100 | } 101 | 102 | if (!user) { 103 | throw new Error('Database User could not be get/put') 104 | } 105 | 106 | res.json({ 107 | token: response.access_token, 108 | user, 109 | }) 110 | } 111 | 112 | /** 113 | * reviewing, auth0 only 114 | * @param req 115 | * @param res 116 | */ 117 | export async function social(req: express.Request, res: express.Response) { 118 | logger.info('Social login', req.body) 119 | const { idToken, accessToken } = req.body 120 | const access = decodeToken(accessToken) 121 | const decoded = decode(idToken) as IdentityToken 122 | const { email, given_name, family_name, picture } = decoded 123 | 124 | if (!access || !email) { 125 | throw new Error('Missing access token or email') 126 | } 127 | 128 | const instance = await UserModel.findOne({ 129 | where: { email }, 130 | }) 131 | let user = instance?.get() 132 | if (user) { 133 | if ( 134 | user.picture !== picture || 135 | user.firstName !== given_name || 136 | user.lastName !== family_name 137 | ) { 138 | user.picture = picture 139 | user.firstName = given_name 140 | user.lastName = family_name 141 | instance?.update(user) 142 | } 143 | } 144 | 145 | if (!user) { 146 | user = await createOrUpdate(UserModel, { 147 | email, 148 | userId: uuid(), 149 | firstName: given_name, 150 | lastName: family_name, 151 | picture, 152 | }) 153 | } 154 | 155 | if (!user) { 156 | throw new Error('Database User could not be found or created') 157 | } 158 | 159 | if (!access.sub) { 160 | throw new Error('No sub in access token') 161 | } 162 | 163 | let renew = false 164 | if (access.uid !== user.userId) { 165 | const ok = await lazyLoadManagementToken() 166 | if (ok) { 167 | logger.info('Updating metadata userId', access.uid, user.userId) 168 | const response = await authProviderPatch(access.sub, { 169 | connection: 'google-oauth2', 170 | user_metadata: { 171 | id: user.userId, 172 | }, 173 | }) 174 | logger.info('success' + JSON.stringify(response)) 175 | renew = true 176 | } 177 | } 178 | 179 | res.json({ 180 | user, 181 | token: accessToken, 182 | renew, 183 | }) 184 | } 185 | 186 | export async function socialCheck(req: express.Request, res: express.Response) { 187 | logger.info('Social Email Check', req.body) 188 | const { token } = req.body 189 | const decoded = decode(token) as IdentityToken 190 | const { email } = decoded 191 | const user = ( 192 | await UserModel.findOne({ 193 | where: { email }, 194 | }) 195 | )?.get() 196 | res.json({ 197 | userId: user?.userId, 198 | }) 199 | } 200 | 201 | export async function forgot(req: express.Request, res: express.Response) { 202 | const payload = req.body 203 | if (!payload) { 204 | throw new Error('Missing payload') 205 | } 206 | 207 | const user = await UserModel.findOne({ where: { email: req.body.email } }) 208 | if (user) { 209 | const message = await authProviderChangePassword(payload) 210 | res.json({ success: true, message }) 211 | } 212 | res.json({ success: false, message: 'No record found' }) 213 | } 214 | 215 | export async function edit(req: express.Request, res: express.Response) { 216 | const payload = req.body 217 | if (!payload) { 218 | throw new Error('Missing payload') 219 | } 220 | const auth = (req as EnrichedRequest).auth as AppAccessToken 221 | payload.userId = auth.uid 222 | const user = await createOrUpdate(UserModel, payload) 223 | res.json({ user }) 224 | } 225 | 226 | export function setPictureIfEmpty(payload: Record): void { 227 | if (!payload?.picture && payload.firstName && payload.lastName) { 228 | payload.picture = getPictureMock(payload) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/routes/profile/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { listHandler } from '../../shared/model-api/routes' 3 | import { UserActiveModel } from '../../shared/types' 4 | import { tokenCheckWare } from '../../shared/auth' 5 | import { edit, forgot, login, register, social, socialCheck } from './controller' 6 | 7 | const router = express.Router() 8 | 9 | /** 10 | * @swagger 11 | * /profile/login: 12 | * post: 13 | * tags: 14 | * - profile 15 | * summary: Login 16 | * requestBody: 17 | * required: true 18 | * content: 19 | * application/json: 20 | * schema: 21 | * type: object 22 | * properties: 23 | * email: 24 | * type: string 25 | * password: 26 | * type: string 27 | * responses: 28 | * 200: 29 | * description: object 30 | * content: 31 | * application/json: 32 | * schema: 33 | * type: object 34 | * properties: 35 | * token: 36 | * type: string 37 | */ 38 | router.post('/profile/login', login) 39 | 40 | /** 41 | * @swagger 42 | * /profile/register: 43 | * post: 44 | * tags: 45 | * - profile 46 | * summary: Register 47 | * requestBody: 48 | * required: true 49 | * content: 50 | * application/json: 51 | * schema: 52 | * type: object 53 | * properties: 54 | * firstName: 55 | * type: string 56 | * lastName: 57 | * type: string 58 | * email: 59 | * type: string 60 | * password: 61 | * type: string 62 | * responses: 63 | * 200: 64 | * description: object 65 | * content: 66 | * application/json: 67 | * schema: 68 | * type: object 69 | * properties: 70 | * token: 71 | * type: string 72 | */ 73 | router.post('/profile/register', register) 74 | 75 | router.post('/profile/edit', tokenCheckWare, edit) 76 | 77 | router.post('/profile/logoff', (_req, res) => { 78 | res.json({ success: true }) 79 | }) 80 | 81 | router.post('/profile/forgot', forgot) 82 | 83 | /** 84 | * @swagger 85 | * /profile/social: 86 | * post: 87 | * tags: 88 | * - profile 89 | */ 90 | router.post('/profile/social', social) 91 | 92 | /** 93 | * @swagger 94 | * /profile/social/check: 95 | * post: 96 | * tags: 97 | * - profile 98 | */ 99 | router.post('/profile/social/check', socialCheck) 100 | 101 | router.get('/active', async (req, res) => { 102 | const handler = listHandler.bind(UserActiveModel) 103 | const items = await handler(req, res) 104 | res.json(items) 105 | }) 106 | 107 | export default router 108 | -------------------------------------------------------------------------------- /src/routes/shop/controller.ts: -------------------------------------------------------------------------------- 1 | import { Cart, CheckoutRequest, Order, OrderStatus, Price, Product } from '../../shared/types' 2 | import express from 'express' 3 | import { CartModel, EnrichedRequest, SubscriptionModel } from '../../shared/types' 4 | import { OrderItemModel, OrderModel } from '../../shared/types/models/order' 5 | import { ProductModel } from 'src/shared/types/models/product' 6 | import { Op } from 'sequelize' 7 | import { createOrUpdate } from 'src/shared/model-api/controller' 8 | import { WalletModel } from 'src/shared/types/models/wallet' 9 | 10 | export async function getTotalCharge(userId: string) { 11 | const items = (await CartModel.findAll({ 12 | where: { userId }, 13 | include: ['drawing', 'product'], 14 | raw: true, 15 | nest: true, 16 | })) as unknown as Cart[] 17 | const subtotal = items.reduce((acc, item) => { 18 | const price = item.priceId 19 | ? item.product?.prices?.find(p => p.id === item.priceId)?.amount ?? 0 20 | : item.drawing?.price ?? 0 21 | return acc + price * item.quantity 22 | }, 0) 23 | // TODO: Add shipping and tax 24 | // TODO: Return metadata with tuple? 25 | return subtotal 26 | } 27 | 28 | export async function checkout(_req: express.Request, res: express.Response) { 29 | const req = _req as EnrichedRequest 30 | const { ids, intent, shippingAddressId } = req.body as CheckoutRequest 31 | // const listTotal = await getTotalCharge(req.auth.userId) 32 | const total = intent?.amount 33 | const order = ( 34 | await OrderModel.create({ 35 | userId: req.auth?.uid, 36 | status: OrderStatus.Pending, 37 | shippingAddressId, 38 | total, 39 | }) 40 | ).get() 41 | 42 | const carts = (await CartModel.findAll({ 43 | raw: true, 44 | include: ['drawing', 'product'], 45 | nest: true, 46 | where: { 47 | cartId: { 48 | [Op.in]: ids.map(id => id.cartId as string), 49 | }, 50 | }, 51 | })) as unknown as Cart[] 52 | 53 | await processCartItems(order, carts) 54 | 55 | if (order.OrderItems?.some(i => i.type === 'subscription')) { 56 | await createOrChangeSubscription(order) 57 | } 58 | 59 | let wallet = undefined 60 | if (order.OrderItems?.some(i => i.type === 'tokens')) { 61 | const creditAmount = (order.OrderItems ?? []).reduce( 62 | (acc, item) => 63 | item.type === 'tokens' ? acc + Number(item.tokens) * Number(item.quantity) : acc, 64 | 0, 65 | ) 66 | wallet = await creditWallet(creditAmount, order.userId as string) 67 | } 68 | 69 | await CartModel.destroy({ where: { userId: req.auth.uid } }) 70 | 71 | if (!wallet) { 72 | wallet = (await WalletModel.findOne({ where: { walletId: req.auth.uid } }))?.get() 73 | } 74 | 75 | res.json({ order, wallet }) 76 | } 77 | 78 | export async function processCartItems(order: Order, carts: Cart[]) { 79 | order.OrderItems = [] 80 | for (const cart of carts) { 81 | const { drawingId, productId, priceId, quantity, cartType } = cart 82 | const price = cart.product?.prices?.find(p => p.id === priceId) 83 | const orderItem = await OrderItemModel.create({ 84 | orderId: order.orderId, 85 | type: cartType, 86 | drawingId, 87 | productId, 88 | priceId, 89 | quantity, 90 | paid: price?.amount ?? cart.drawing?.price ?? 0, 91 | tokens: cartType === 'tokens' ? price?.divide_by ?? 0 : 0, 92 | }) 93 | order.OrderItems.push({ ...orderItem.get(), product: { ...cart.product, ...price } }) 94 | } 95 | } 96 | 97 | export async function creditWallet(creditAmount: number, userId: string) { 98 | const existingWallet = (await WalletModel.findOne({ where: { walletId: userId } }))?.get() 99 | const existingBalance = parseFloat(existingWallet?.balance as unknown as string) || 0 100 | const balance = creditAmount + existingBalance 101 | const [item] = await WalletModel.upsert({ 102 | walletId: userId, 103 | balance, 104 | }) 105 | const result = item.get() 106 | return result 107 | } 108 | 109 | export async function createOrChangeSubscription(order: Order) { 110 | const userId = order.userId as string 111 | const rows = await SubscriptionModel.findAll({ 112 | where: { userId, status: 'active' }, 113 | }) 114 | const existing = rows.find( 115 | s => order.OrderItems?.length && order.OrderItems[0].priceId === s.getDataValue('priceId'), 116 | ) 117 | order.subscription = existing?.get() 118 | const others = rows.filter( 119 | s => s.getDataValue('subscriptionId') !== order.subscription?.subscriptionId, 120 | ) 121 | for (const subscription of others) { 122 | await subscription.update({ 123 | status: 'canceled', 124 | canceledAt: new Date(), 125 | cancelationReason: 'subscription.change', 126 | }) 127 | } 128 | 129 | // TODO: existing, maybe renew? 130 | 131 | if (!order.subscription) { 132 | order.subscription = ( 133 | await SubscriptionModel.create({ 134 | subscriptionId: order.orderId, 135 | userId, 136 | orderId: order.orderId, 137 | priceId: order.OrderItems?.length ? order.OrderItems[0].priceId : undefined, 138 | status: 'active', 139 | title: order.OrderItems?.length ? order.OrderItems[0].product?.title : undefined, 140 | }) 141 | ).get() 142 | } 143 | } 144 | 145 | export async function addSubscriptionToCart(req: express.Request, res: express.Response) { 146 | const { uid: userId } = (req as EnrichedRequest).auth 147 | const cart = { ...req.body, userId } as unknown as Cart 148 | const { productId, priceId } = cart 149 | const product = (await ProductModel.findByPk(productId, { raw: true })) as unknown as Product 150 | const price = product.prices?.find(p => p.id === priceId) as Price 151 | if (!product || !price) { 152 | throw new Error(`Price ${priceId} not found for product ${productId}`) 153 | } 154 | await CartModel.destroy({ where: { userId, cartType: 'subscription' } }) 155 | const saved = await createOrUpdate(CartModel, cart) 156 | saved.product = { ...product, ...price } 157 | res.json({ ...saved }) 158 | } 159 | -------------------------------------------------------------------------------- /src/routes/shop/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { addSubscriptionToCart, checkout } from './controller' 3 | import { capturePaymentHandler, createOrderHandler } from './paypal' 4 | 5 | const router = express.Router() 6 | 7 | router.post('/shop/checkout', checkout) 8 | 9 | router.post('/shop/subscribe', addSubscriptionToCart) 10 | 11 | router.post('/paypal/order', createOrderHandler) 12 | 13 | router.post('/paypal/orders/:orderID/capture', capturePaymentHandler) 14 | 15 | export default router 16 | -------------------------------------------------------------------------------- /src/routes/shop/paypal.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import express from 'express' 3 | 4 | const { CLIENT_ID, APP_SECRET } = process['env'] 5 | const base = 'https://api-m.sandbox.paypal.com' 6 | 7 | export async function capturePaymentHandler(req: express.Request, res: express.Response) { 8 | const { orderID } = req.params 9 | const captureData = await capturePayment(orderID) 10 | // TODO: store payment information such as the transaction ID 11 | res.json(captureData) 12 | } 13 | 14 | export async function createOrderHandler(req: express.Request, res: express.Response) { 15 | const orderData = await createOrder() 16 | res.json(orderData) 17 | } 18 | 19 | // use the orders api to create an order 20 | async function createOrder() { 21 | const accessToken = await generateAccessToken() 22 | const url = `${base}/v2/checkout/orders` 23 | const response = await axios.post( 24 | url, 25 | { 26 | intent: 'CAPTURE', 27 | purchase_units: [ 28 | { 29 | amount: { 30 | currency_code: 'USD', 31 | value: '100.00', 32 | }, 33 | }, 34 | ], 35 | }, 36 | { 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | Authorization: `Bearer ${accessToken}`, 40 | }, 41 | }, 42 | ) 43 | return response.data 44 | } 45 | 46 | async function capturePayment(orderId: string) { 47 | const accessToken = await generateAccessToken() 48 | const url = `${base}/v2/checkout/orders/${orderId}/capture` 49 | const response = await axios.post( 50 | url, 51 | {}, 52 | { 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | Authorization: `Bearer ${accessToken}`, 56 | }, 57 | }, 58 | ) 59 | return response.data 60 | } 61 | 62 | async function generateAccessToken() { 63 | const auth = Buffer.from(CLIENT_ID + ':' + APP_SECRET).toString('base64') 64 | const response = await axios.post(`${base}/v1/oauth2/token`, 'grant_type=client_credentials', { 65 | headers: { 66 | Authorization: `Basic ${auth}`, 67 | }, 68 | }) 69 | return response.data.access_token 70 | } 71 | -------------------------------------------------------------------------------- /src/routes/swagger.yaml: -------------------------------------------------------------------------------- 1 | components: 2 | securitySchemes: 3 | BearerAuth: 4 | type: http 5 | scheme: bearer 6 | in: header 7 | responses: 8 | 403Unauthorized: 9 | description: You do not have permission to access this 10 | content: 11 | application/json: 12 | schema: 13 | type: object 14 | properties: 15 | statusCode: 16 | type: integer 17 | example: 403 18 | message: 19 | type: string 20 | example: Unauthorized 21 | 500Error: 22 | description: Unable to process the request 23 | content: 24 | application/json: 25 | schema: 26 | type: object 27 | properties: 28 | statusCode: 29 | type: integer 30 | example: 500 31 | message: 32 | type: string 33 | example: Internal Server Error 34 | -------------------------------------------------------------------------------- /src/shared/auth/auth0.configure.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { lazyLoadManagementToken } from '.' 3 | import { config } from '../config' 4 | import logger from '../logger' 5 | 6 | const readOptions = () => ({ 7 | headers: { 8 | Authorization: `Bearer ${config.auth.manageToken || 'error not set'}`, 9 | }, 10 | validateStatus: () => true, 11 | }) 12 | 13 | const log = (s: string, o?: unknown) => 14 | config.auth.trace 15 | ? logger.info(`AUTH0-SYNC::${s}: ${o ? JSON.stringify(o, null, 2) : ''}`) 16 | : () => { 17 | /**/ 18 | } 19 | const get = (url: string) => axios.get(`${config.auth?.baseUrl}/api/v2/${url}`, readOptions()) 20 | const post = (url: string, data: unknown) => 21 | axios.post(`${config.auth?.baseUrl}/api/v2/${url}`, data, readOptions()) 22 | 23 | /** 24 | * Auth0 can be tricky enough to merit, experimental automated setup 25 | * Check exists and if not found, create: 26 | * - Check for Resource Servers 27 | * - Check for Clients and sets clientId and clientSecret 28 | * - Check for Client Grants 29 | * - Check for Rules 30 | */ 31 | export async function auth0AutoConfigure(): Promise { 32 | logger.info('Auth0: Sync()') 33 | if (process.env.NODE_ENV === 'test') { 34 | logger.info('Auth0: Skipped for Tests') 35 | return true 36 | } 37 | if (!config.auth.sync) { 38 | logger.info('Auth0: Sync Off') 39 | return false 40 | } 41 | if (!config.auth.enabled) { 42 | logger.info('Auth0: Offline Mode') 43 | return false 44 | } 45 | if (!config.auth.tenant || !config.auth.explorerId || !config.auth.explorerSecret) { 46 | log('Auth0: explorer credentials not set - skipping sync') 47 | // eslint-disable-next-line no-console 48 | console.warn( 49 | '\x1b[33m*****************************\n\x1b[33m*** AUTH_TENANT AUTH_EXPLORER_ID AND AUTH_EXPLORER_SECRET ARE NOT SET - AUTH0 SYNC TURNED OFF ***\n\x1b[33m***************************** \x1b[0m', 50 | ) 51 | return false 52 | } 53 | const success = await lazyLoadManagementToken() 54 | if (!success) { 55 | logger.warn('Failed to get auth0 management token - skipping') 56 | return false 57 | } 58 | if (!config.auth.manageToken) { 59 | logger.error('Auth0: management token not set - aborting sync') 60 | return false 61 | } 62 | await ensureResourceServers() 63 | await ensureClients() 64 | await ensureRules() 65 | if (config.auth.clientId) { 66 | log( 67 | `Auth0 Check Complete > AUTH_CLIENT_ID: ${config.auth.clientId} > For clients use GET /config`, 68 | ) 69 | } 70 | return true 71 | } 72 | 73 | async function ensureClients() { 74 | interface AuthClient { 75 | id: string 76 | name: string 77 | is_system: boolean 78 | identifier: string 79 | scopes: string[] 80 | client_id: string 81 | client_secret: string 82 | [key: string]: unknown 83 | } 84 | 85 | interface Grant { 86 | id: string 87 | client_id: string 88 | audience: string 89 | scope: string[] 90 | } 91 | 92 | const clientClient = { 93 | name: 'client', 94 | allowed_clients: [], 95 | allowed_logout_urls: [], 96 | callbacks: [ 97 | 'http://localhost:3000', 98 | 'http://localhost:3000/callback', 99 | 'http://localhost:3001', 100 | 'https://accounts.google.com/gsi/client', 101 | ], 102 | native_social_login: { 103 | apple: { 104 | enabled: false, 105 | }, 106 | facebook: { 107 | enabled: false, 108 | }, 109 | }, 110 | allowed_origins: ['http://localhost:3000'], 111 | client_aliases: [], 112 | token_endpoint_auth_method: 'client_secret_post', 113 | app_type: 'regular_web', 114 | grant_types: [ 115 | 'authorization_code', 116 | 'implicit', 117 | 'refresh_token', 118 | 'client_credentials', 119 | 'password', 120 | 'http://auth0.com/oauth/grant-type/password-realm', 121 | 'http://auth0.com/oauth/grant-type/passwordless/otp', 122 | 'http://auth0.com/oauth/grant-type/mfa-oob', 123 | 'http://auth0.com/oauth/grant-type/mfa-otp', 124 | 'http://auth0.com/oauth/grant-type/mfa-recovery-code', 125 | ], 126 | web_origins: ['http://localhost:3000'], 127 | custom_login_page_on: true, 128 | } 129 | 130 | const backendClient = { 131 | name: 'backend', 132 | token_endpoint_auth_method: 'client_secret_post', 133 | app_type: 'non_interactive', 134 | grant_types: ['client_credentials'], 135 | custom_login_page_on: true, 136 | } 137 | 138 | const grants = (await get('client-grants'))?.data 139 | const grant = async (client: AuthClient, audience: string) => { 140 | log(`Creating client grant for ${client.name}`) 141 | const grantResult = await post(`client-grants`, { 142 | client_id: client.client_id, 143 | audience, 144 | scope: [], 145 | }) 146 | if (grantResult.data?.id) { 147 | log(`Created client grant`, grantResult.data) 148 | grants.push(grantResult.data) 149 | } else { 150 | logger.error( 151 | `Failed to create client grant for ${client.name}` + JSON.stringify(grantResult.data), 152 | ) 153 | } 154 | } 155 | 156 | const addClient = async (client: Partial, audience: string) => { 157 | log('Creating client', { client, audience }) 158 | const result = await post(`clients`, client) 159 | if (!result.data?.client_id) { 160 | logger.error(`Failed to create client ${client.name}` + JSON.stringify(result.data)) 161 | } 162 | log(`Created client ${client.name}`, result.data) 163 | await grant(result.data, audience) 164 | return result.data 165 | } 166 | 167 | const existing = await get(`clients`) 168 | const authManager = existing.data.find(c => c.name === 'API Explorer Application') 169 | let existingBackend = existing.data.find(e => e.name === 'backend') 170 | if (!existingBackend) { 171 | existingBackend = await addClient(backendClient, config.auth.explorerAudience) 172 | } 173 | let existingClient = existing.data.find(e => e.name === 'client') 174 | if (!existingClient) { 175 | existingClient = await addClient(clientClient, config.auth.clientAudience) 176 | } 177 | 178 | // backend has manager 179 | // client has backend 180 | if ( 181 | existingBackend && 182 | authManager?.identifier && 183 | !grants.find( 184 | g => g.client_id === existingBackend?.client_id && g.audience === authManager.identifier, 185 | ) 186 | ) { 187 | await grant(existingBackend, authManager?.identifier) 188 | } 189 | 190 | if ( 191 | existingClient && 192 | !grants.find( 193 | g => g.client_id === existingClient?.client_id && g.audience === config.auth.clientAudience, 194 | ) 195 | ) { 196 | grant(existingClient, config.auth.clientAudience) 197 | } 198 | 199 | if (!config.auth.clientId && existingClient) { 200 | log(`Setting config.auth.clientId to ${existingClient.client_id}`) 201 | config.auth.clientId = existingClient.client_id 202 | config.auth.clientSecret = existingClient.client_secret 203 | } else { 204 | log(`Auth.clientId already set to ${config.auth.clientId}`, config.auth) 205 | } 206 | } 207 | 208 | async function ensureResourceServers() { 209 | const resourceServers = [ 210 | { 211 | name: 'backend', 212 | identifier: config.auth.clientAudience, 213 | }, 214 | ] 215 | 216 | interface ResourceServer { 217 | id: string 218 | name: string 219 | is_system: boolean 220 | identifier: string 221 | scopes: string[] 222 | } 223 | 224 | const existing = await get(`resource-servers`) 225 | const missing = resourceServers.filter( 226 | rs => !existing.data.find(e => e.identifier === rs.identifier), 227 | ) 228 | if (missing.length) { 229 | log(`Creating missing resource servers: ${missing.map(m => m.name).join(', ')}`) 230 | for (const rs of missing) { 231 | const result = await post(`resource-servers`, rs) 232 | log(`Created resource server ${rs.name} with id ${result.data.id}`) 233 | } 234 | } 235 | } 236 | 237 | async function ensureRules() { 238 | const rules = [ 239 | { 240 | name: 'enrichToken', 241 | script: 242 | 'function enrichToken(user, context, callback) {\n' + 243 | ' let accessTokenClaims = context.accessToken || {};\n' + 244 | ' const assignedRoles = (context.authorization || {}).roles;\n' + 245 | ' accessTokenClaims[`https://roles`] = assignedRoles;\n' + 246 | ' user.user_metadata = user.user_metadata || {};\n' + 247 | ' if (!user.user_metadata.id && context.request.query.app_user_id) {\n' + 248 | ' user.user_metadata.id = context.request.query.app_user_id;\n' + 249 | ' }\n' + 250 | ' accessTokenClaims[`https://userId`] = user.user_metadata.id;\n' + 251 | ' accessTokenClaims[`https://verified`] = user.email_verified;\n' + 252 | ' context.accessToken = accessTokenClaims;\n' + 253 | ' return callback(null, user, context);\n' + 254 | '}', 255 | order: 1, 256 | enabled: true, 257 | }, 258 | ] 259 | 260 | interface Rule { 261 | id: string 262 | name: string 263 | script: string 264 | order: number 265 | enabled: boolean 266 | stage: string 267 | } 268 | 269 | const existing = await get(`rules`) 270 | const missing = rules.filter(rule => !existing.data.find(erule => erule.name === rule.name)) 271 | if (missing.length) { 272 | log('Missing rules, creating... ' + JSON.stringify(missing)) 273 | for (const rule of missing) { 274 | const result = await post(`rules`, rule) 275 | log('Result: ' + JSON.stringify(result.data)) 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/shared/auth/auth0.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios' 2 | import config from '../config' 3 | import { oAuthInputs, oAuthResponse } from '../types' 4 | 5 | export async function auth0Register(payload: oAuthInputs): Promise { 6 | try { 7 | const response = await axios.post(`${config.auth?.baseUrl}/dbconnections/signup`, { 8 | connection: 'Username-Password-Authentication', 9 | client_id: config.auth?.clientId, 10 | email: payload.email, 11 | password: payload.password, 12 | user_metadata: { 13 | id: payload.uid, 14 | }, 15 | }) 16 | return response.data 17 | } catch (err) { 18 | const error = err as Error & { response: AxiosResponse } 19 | return { 20 | error: error.response?.data?.name ?? error.message, 21 | error_description: error.response?.data?.description ?? error.message, 22 | } 23 | } 24 | } 25 | 26 | export async function auth0Login({ email, password }: oAuthInputs): Promise { 27 | try { 28 | const response = await axios.post(`${config.auth?.baseUrl}/oauth/token`, { 29 | client_id: config.auth?.clientId, 30 | client_secret: config.auth?.clientSecret, 31 | audience: `${config.auth?.baseUrl}/api/v2/`, 32 | grant_type: 'password', 33 | username: email, 34 | password, 35 | }) 36 | return response.data 37 | } catch (err) { 38 | const error = err as Error & { response: AxiosResponse } 39 | return { 40 | error: error.response?.data?.error || error.message, 41 | error_description: error.response?.data?.message || error.message, 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/shared/auth/firebase.ts: -------------------------------------------------------------------------------- 1 | import { DecodedIdToken, getAuth } from 'firebase-admin/auth' 2 | import { getFirebaseApp } from '../firebase' 3 | import { UserModel } from '../types/models' 4 | import { oAuthError, oAuthInputs, oAuthRegistered, oAuthResponse } from '../types' 5 | import { decode } from 'jsonwebtoken' 6 | import { OAuth2Client } from 'google-auth-library' 7 | import { getSettingsAsync } from '../settings' 8 | import { randomUUID } from 'crypto' 9 | 10 | export interface FirebaseAuthResponse { 11 | kind: string 12 | localId: string 13 | email: string 14 | displayName: string 15 | idToken: string 16 | registered: boolean 17 | refreshToken: string 18 | expiresIn: string 19 | } 20 | 21 | /** 22 | * Client SDK does the signin and signup calls with this idToken as a result 23 | * @param param0 24 | * @returns 25 | */ 26 | export const firebaseCredentialLogin = async ({ idToken }: oAuthInputs): Promise => { 27 | try { 28 | const app = await getFirebaseApp() 29 | const auth = getAuth(app) 30 | const settings = await getSettingsAsync() 31 | const clientId = settings?.google?.clientId 32 | const projectId = settings?.google?.projectId 33 | const aud = (decode(idToken as string) as { aud: string })?.aud as string 34 | let result: DecodedIdToken 35 | if (aud === projectId) { 36 | result = await auth.verifyIdToken(idToken as string, true) 37 | } else { 38 | result = ( 39 | await new OAuth2Client(clientId).verifyIdToken({ 40 | idToken: idToken as string, 41 | audience: clientId, 42 | }) 43 | ).getPayload() as DecodedIdToken 44 | } 45 | let user = ( 46 | await UserModel.findOne({ 47 | where: { 48 | email: result.email, 49 | }, 50 | }) 51 | )?.get() 52 | 53 | if (!user) { 54 | user = ( 55 | await UserModel.create({ 56 | email: result.email as string, 57 | userId: randomUUID(), 58 | picture: result.picture, 59 | firstName: result.given_name || result.display_name, 60 | }) 61 | )?.get() 62 | } 63 | 64 | const access_token = await auth.createCustomToken(user.userId, { 65 | roles: user?.roles?.length ? user.roles : [], 66 | }) 67 | 68 | return { 69 | access_token, 70 | } 71 | } catch (err) { 72 | const error = err as Error 73 | return { 74 | error: error.message, 75 | error_description: error.message, 76 | } 77 | } 78 | } 79 | 80 | export const firebaseRegister = async ({ 81 | email, 82 | ...payload 83 | }: oAuthInputs): Promise => { 84 | try { 85 | const app = await getFirebaseApp() 86 | const auth = getAuth(app) 87 | const result = await auth.createUser({ 88 | ...payload, 89 | email, 90 | }) 91 | 92 | return { ...result } as unknown as oAuthRegistered 93 | } catch (err) { 94 | const error = err as Error 95 | return { 96 | error: error.message, 97 | } 98 | } 99 | } 100 | 101 | export const firebaseCreateToken = async ( 102 | uid: string, 103 | claims: { roles: string[] }, 104 | ): Promise => { 105 | try { 106 | const app = await getFirebaseApp() 107 | const auth = getAuth(app) 108 | const access_token = await auth.createCustomToken(uid, claims) 109 | return access_token 110 | } catch (err) { 111 | const error = err as Error 112 | return { 113 | error: error.message, 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/shared/auth/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios' 2 | import express from 'express' 3 | import { expressjwt } from 'express-jwt' 4 | import jwksRsa from 'jwks-rsa' 5 | import jwt from 'jsonwebtoken' 6 | import { config } from '../config' 7 | import { EnrichedRequest } from '../types' 8 | import { Connection, EntityConfig } from '../db' 9 | import logger from '../logger' 10 | import { HttpUnauthorizedError } from '../errorHandler' 11 | import { 12 | AppAccessToken, 13 | AuthProviders, 14 | SettingState, 15 | oAuthError, 16 | oAuthInputs, 17 | oAuthRegistered, 18 | oAuthResponse, 19 | } from '../types' 20 | import { auth0Login, auth0Register } from './auth0' 21 | import { firebaseCredentialLogin, firebaseRegister } from 'src/shared/auth/firebase' 22 | import { getSettingsAsync } from '../settings' 23 | 24 | export const getAuthSettingsAsync = async () => { 25 | const settings = await getSettingsAsync() 26 | const authProvider = settings?.system?.authProvider || AuthProviders.None 27 | const isDevelopment = authProvider === AuthProviders.Development 28 | const isNone = authProvider === AuthProviders.None 29 | const startAdminEmail = settings?.internal?.startAdminEmail 30 | const enableRegistration = !isNone && settings?.system?.enableRegistration 31 | return { settings, authProvider, isDevelopment, isNone, startAdminEmail, enableRegistration } 32 | } 33 | 34 | let jwkClient: jwksRsa.JwksClient 35 | export async function getJwkClient() { 36 | const settings = await getSettingsAsync() 37 | const authProvider = settings?.system?.authProvider || AuthProviders.None 38 | if (authProvider !== AuthProviders.Auth0) { 39 | return null 40 | } 41 | 42 | if (!jwkClient) { 43 | jwkClient = jwksRsa({ 44 | jwksUri: `https://${settings?.auth0?.tenant}.auth0.com/.well-known/jwks.json`, 45 | cache: true, 46 | rateLimit: true, 47 | }) 48 | } 49 | return jwkClient 50 | } 51 | 52 | let jwtVerify: (req: express.Request, res: express.Response, next: express.NextFunction) => void 53 | export function getJwtVerify() { 54 | if (!jwtVerify) { 55 | jwtVerify = expressjwt({ 56 | secret: config.auth.tokenSecret || 'off', 57 | algorithms: ['HS256'], 58 | }) 59 | } 60 | return jwtVerify 61 | } 62 | 63 | export type ModelWare = { 64 | config: EntityConfig 65 | authWare: express.Handler 66 | } 67 | 68 | const readMethods = ['GET', 'HEAD', 'OPTIONS'] 69 | const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE'] 70 | 71 | async function getVerifyKey(settings: SettingState, kid: string) { 72 | const authProvider = settings?.system?.authProvider || AuthProviders.None 73 | switch (authProvider) { 74 | case AuthProviders.Firebase: { 75 | const json = JSON.parse(settings?.internal?.secrets?.google?.serviceAccountJson || '{}') 76 | const key = json.private_key 77 | return key 78 | } 79 | default: { 80 | const client = await getJwkClient() 81 | if (!client) { 82 | throw Error('No JWK client') 83 | } 84 | const result = await client.getSigningKey(kid) 85 | return result.getPublicKey() 86 | } 87 | } 88 | } 89 | 90 | export async function checkToken(header: jwt.JwtHeader | undefined, token: string | undefined) { 91 | const settings = await getSettingsAsync() 92 | const authProvider = settings?.system?.authProvider || AuthProviders.None 93 | const readyMap = { 94 | [AuthProviders.None]: false, 95 | [AuthProviders.Development]: false, 96 | [AuthProviders.Auth0]: 97 | !!settings?.auth0?.clientId && !!settings?.internal?.secrets?.auth0?.clientSecret, 98 | [AuthProviders.Firebase]: !!settings?.internal?.secrets?.google?.serviceAccountJson, 99 | } 100 | let accessToken: AppAccessToken | undefined 101 | if (readyMap[authProvider] && header?.alg === 'RS256' && header && token) { 102 | const key = await getVerifyKey(settings, header.kid as string) 103 | if (!key) { 104 | throw Error('Could not resolve private key for RS256') 105 | } 106 | accessToken = jwt.verify(token, key, { 107 | algorithms: ['RS256'], 108 | }) as AppAccessToken 109 | } else if (token) { 110 | accessToken = jwt.verify(token, config.auth.tokenSecret as string) as AppAccessToken 111 | } 112 | return accessToken 113 | } 114 | 115 | export async function tokenCheckWare( 116 | req: express.Request, 117 | _res: express.Response, 118 | next: express.NextFunction, 119 | ) { 120 | try { 121 | const { header, token } = setRequest(req) 122 | if (!config.auth.enabled) { 123 | return next() 124 | } 125 | const accessToken = await checkToken(header, token) 126 | if (!accessToken) { 127 | throw Error('Not logged in') 128 | } 129 | return next() 130 | } catch (err) { 131 | logger.error(err) 132 | const error = err as Error 133 | throw new HttpUnauthorizedError(error.message) 134 | } 135 | } 136 | export async function modelAuthMiddleware( 137 | req: express.Request, 138 | _res: express.Response, 139 | next: express.NextFunction, 140 | ) { 141 | try { 142 | const entity = Connection.entities.find(e => e.name === req.originalUrl.replace('/', '')) 143 | const { header, token } = setRequest(req, entity) 144 | if (!entity || !config.auth.enabled) { 145 | return next() 146 | } 147 | if ( 148 | (entity?.publicRead && readMethods.includes(req.method)) || 149 | (entity?.publicWrite && writeMethods.includes(req.method)) 150 | ) { 151 | return next() 152 | } 153 | // If not public, we need a token 154 | const accessToken = await checkToken(header, token) 155 | if (entity && !accessToken) { 156 | throw Error('Not logged in') 157 | } 158 | // Valid, but let's check if user has access role 159 | const roles = (accessToken?.claims?.roles as string[]) || accessToken?.roles || [] 160 | if ( 161 | entity && 162 | accessToken && 163 | entity.roles?.length && 164 | !entity.roles?.every(r => roles.includes(r)) 165 | ) { 166 | throw Error('Needs user access role for request') 167 | } 168 | // All good 169 | return next() 170 | } catch (err) { 171 | logger.error(err) 172 | const error = err as Error 173 | throw new HttpUnauthorizedError(error.message) 174 | } 175 | } 176 | 177 | export function setRequest( 178 | r: express.Request, 179 | cfg?: EntityConfig, 180 | ): { 181 | header?: jwt.JwtHeader 182 | token?: string 183 | } { 184 | const req = r as EnrichedRequest 185 | req.config = cfg 186 | 187 | if (!req.headers?.authorization?.includes('Bearer ')) { 188 | return {} 189 | } 190 | const token = req.headers.authorization.split(' ')[1] 191 | const headerChunk = token.split('.')[0] 192 | const decoded = Buffer.from(headerChunk, 'base64').toString() 193 | if (!decoded.includes('{')) { 194 | return {} 195 | } 196 | req.auth = decodeToken(token) as AppAccessToken 197 | const header = JSON.parse(decoded) as jwt.JwtHeader 198 | return { header, token } 199 | } 200 | 201 | /** 202 | * HS256 token encoding 203 | * Not for auth0 or any providers without private key 204 | * @param obj 205 | * @returns 206 | */ 207 | export function createToken(obj: object): string { 208 | const token = jwt.sign(obj, config.auth.tokenSecret as string) 209 | return token 210 | } 211 | 212 | export function decodeToken(token: string): AppAccessToken { 213 | if (!token) { 214 | return undefined as unknown as AppAccessToken 215 | } 216 | const authInfo = jwt.decode(token) as jwt.JwtPayload 217 | if (!authInfo) { 218 | return undefined as unknown as AppAccessToken 219 | } 220 | const prefix = config.auth?.ruleNamespace || 'https://' 221 | const keys = Object.keys(authInfo).filter(key => key.includes(prefix)) 222 | for (const key of keys) { 223 | authInfo[key.replace(prefix, '')] = authInfo[key] 224 | delete authInfo[key] 225 | } 226 | return authInfo as AppAccessToken 227 | } 228 | 229 | const loginMethods: Record Promise> = { 230 | [AuthProviders.Auth0]: auth0Login, 231 | [AuthProviders.Firebase]: firebaseCredentialLogin, 232 | } 233 | 234 | export async function authProviderLogin(args: oAuthInputs): Promise { 235 | const { authProvider } = await getAuthSettingsAsync() 236 | const response = await loginMethods[authProvider](args) 237 | return response 238 | } 239 | 240 | export async function fakeMockRegister(payload: oAuthInputs): Promise { 241 | const result = await new Promise( 242 | resolve => 243 | setTimeout(() => { 244 | return resolve({ 245 | data: { 246 | ...payload, 247 | provider: 'mockRegister', 248 | }, 249 | }) 250 | }, 1000) as unknown as oAuthRegistered, 251 | ) 252 | return result as oAuthRegistered 253 | } 254 | 255 | const registerMethods: Record Promise> = { 256 | [AuthProviders.Auth0]: auth0Register, 257 | [AuthProviders.Firebase]: firebaseRegister, 258 | [AuthProviders.Development]: fakeMockRegister, 259 | } 260 | 261 | export async function authProviderRegister(payload: oAuthInputs): Promise { 262 | try { 263 | const { authProvider } = await getAuthSettingsAsync() 264 | const method = registerMethods[authProvider] 265 | const response = await method(payload) 266 | return response 267 | } catch (err: unknown) { 268 | const error = err as Error & { 269 | response: AxiosResponse 270 | } 271 | return { 272 | error: error.response?.data?.name ?? error.message, 273 | error_description: error.response?.data?.description ?? error.message, 274 | } 275 | } 276 | } 277 | 278 | export async function authProviderChangePassword( 279 | payload: Record, 280 | ): Promise { 281 | try { 282 | const response = await axios.post(`${config.auth?.baseUrl}/dbconnections/change_password`, { 283 | connection: 'Username-Password-Authentication', 284 | client_id: config.auth?.clientId, 285 | email: payload.email, 286 | }) 287 | return response.data 288 | } catch (err: unknown) { 289 | const error = err as Error & { response: AxiosResponse } 290 | return { 291 | error: error.response?.data?.name, 292 | error_description: error.response?.data?.description, 293 | } 294 | } 295 | } 296 | 297 | export async function authProviderPatch( 298 | sub: string, 299 | payload: { 300 | connection: string 301 | user_metadata: Record 302 | [key: string]: unknown 303 | }, 304 | ): Promise { 305 | try { 306 | const token = config.auth?.manageToken 307 | const response = await axios.patch( 308 | `${config.auth?.baseUrl}/api/v2/users/${sub}`, 309 | { 310 | ...payload, 311 | }, 312 | { 313 | headers: { 314 | Authorization: `Bearer ${token}`, 315 | }, 316 | }, 317 | ) 318 | return response.data 319 | } catch (err: unknown) { 320 | const error = err as Error & { response: AxiosResponse } 321 | return { 322 | error: error.response?.data?.error, 323 | error_description: error.response?.data?.message, 324 | } 325 | } 326 | } 327 | 328 | export async function lazyLoadManagementToken(): Promise { 329 | if (!config.auth?.explorerId) { 330 | return false 331 | } 332 | 333 | if (config.auth?.manageToken) { 334 | const decoded = jwt.decode(config.auth?.manageToken) as jwt.JwtPayload 335 | if (decoded.exp && decoded.exp > Date.now() / 1000) { 336 | return true 337 | } else { 338 | logger.info('Management token expired') 339 | config.auth.manageToken = undefined 340 | } 341 | } 342 | 343 | logger.info('Getting management token...') 344 | const response = await axios.post( 345 | `${config.auth?.baseUrl}/oauth/token`, 346 | { 347 | client_id: config.auth.explorerId, 348 | client_secret: config.auth.explorerSecret, 349 | audience: config.auth.explorerAudience, 350 | grant_type: 'client_credentials', 351 | }, 352 | { 353 | validateStatus: () => true, 354 | }, 355 | ) 356 | 357 | if (config.trace) { 358 | logger.info('response' + JSON.stringify(response.data)) 359 | } 360 | 361 | if (response.data.access_token) { 362 | config.auth.manageToken = response.data.access_token 363 | } 364 | 365 | return true 366 | } 367 | -------------------------------------------------------------------------------- /src/shared/config/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "db": { 4 | "url": null, 5 | "username": "postgres", 6 | "password": "postgrespass", 7 | "database": "drawspace", 8 | "schema": "public", 9 | "host": "127.0.0.1", 10 | "ssl": false, 11 | "dialect": "postgres" 12 | }, 13 | "service": { 14 | "port": 3001, 15 | "protocol": "http", 16 | "host": "localhost", 17 | "sslKey": null, 18 | "sslCert": null 19 | } 20 | }, 21 | "production": { 22 | "db": { 23 | "url": "$DB_URL", 24 | "schema": "public", 25 | "ssl": false 26 | }, 27 | "service": { 28 | "port": "8080", 29 | "protocol": "http", 30 | "host": "localhost", 31 | "sslKey": "$SSL_KEY", 32 | "sslCert": "$SSL_CERT" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/config/auth0.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "name": "backend", 5 | "identifier": "https://backend", 6 | "allow_offline_access": false, 7 | "skip_consent_for_verifiable_first_party_clients": true, 8 | "token_lifetime": 86400, 9 | "token_lifetime_for_web": 7200, 10 | "signing_alg": "RS256" 11 | } 12 | ], 13 | "clients": [ 14 | { 15 | "name": "client", 16 | "allowed_clients": [], 17 | "allowed_logout_urls": [], 18 | "callbacks": [ 19 | "http://localhost:3000", 20 | "http://localhost:3000/callback", 21 | "http://localhost:3001", 22 | "https://accounts.google.com/gsi/client" 23 | ], 24 | "allowed_origins": [ 25 | "http://localhost:3000" 26 | ], 27 | "client_aliases": [], 28 | "token_endpoint_auth_method": "client_secret_post", 29 | "app_type": "regular_web", 30 | "grant_types": [ 31 | "authorization_code", 32 | "implicit", 33 | "refresh_token", 34 | "client_credentials", 35 | "password", 36 | "http://auth0.com/oauth/grant-type/password-realm", 37 | "http://auth0.com/oauth/grant-type/passwordless/otp", 38 | "http://auth0.com/oauth/grant-type/mfa-oob", 39 | "http://auth0.com/oauth/grant-type/mfa-otp", 40 | "http://auth0.com/oauth/grant-type/mfa-recovery-code" 41 | ], 42 | "web_origins": [ 43 | "http://localhost:3000" 44 | ], 45 | "custom_login_page_on": true 46 | }, 47 | { 48 | "name": "backend (Test Application)", 49 | "app_type": "non_interactive", 50 | "grant_types": [ 51 | "client_credentials" 52 | ], 53 | "custom_login_page_on": true 54 | } 55 | ], 56 | "rules": [ 57 | { 58 | "enabled": true, 59 | "script": "function enrichToken(user, context, callback) {\n let accessTokenClaims = context.accessToken || {};\n const assignedRoles = (context.authorization || {}).roles;\n accessTokenClaims[`https://roles`] = assignedRoles;\n user.user_metadata = user.user_metadata || {};\n accessTokenClaims[`https://userId`] = user.user_metadata.id;\n accessTokenClaims[`https://verified`] = user.email_verified;\n context.accessToken = accessTokenClaims;\n return callback(null, user, context);\n}", 60 | "name": "enrichToken", 61 | "order": 1 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/shared/config/index.ts: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import { OAS3Definition } from 'swagger-jsdoc' 3 | import packageJson from '../../../package.json' 4 | import appConfig from './app.json' 5 | import logger from '../logger' 6 | import dotenv from 'dotenv' 7 | 8 | // Anti-webpack sorcery 9 | const env = process['env'] 10 | 11 | export interface DBUrl { 12 | dialect?: string 13 | username?: string 14 | password?: string 15 | host: string 16 | database: string 17 | ssl: boolean 18 | schema: string 19 | } 20 | 21 | export interface DBConfig extends DBUrl { 22 | trace: boolean 23 | url: string 24 | sync: boolean 25 | force: boolean 26 | alter: boolean 27 | models: string[] 28 | } 29 | 30 | export interface Config { 31 | isLocalhost: boolean 32 | trace: boolean 33 | production: boolean 34 | hostname?: string 35 | port?: number 36 | protocol: string 37 | backendBaseUrl: Readonly 38 | jsonLimit: string 39 | sslKey?: string 40 | sslCert?: string 41 | cors: { 42 | origin: string 43 | } 44 | db: DBConfig 45 | auth: { 46 | enabled: boolean 47 | sync: boolean 48 | trace: boolean 49 | tokenSecret?: string 50 | tenant: string 51 | domain: string 52 | baseUrl: string 53 | redirectUrl: string 54 | explorerAudience: string 55 | explorerId: string 56 | explorerSecret: string 57 | ruleNamespace: string 58 | algorithm: 'RS256' | 'HS256' 59 | clientAudience: string 60 | clientId?: string 61 | clientSecret?: string 62 | manageToken?: string 63 | } 64 | swaggerSetup: Partial 65 | } 66 | 67 | export function parseDatabaseConfig( 68 | production: boolean, 69 | db: { url: string | null; ssl: boolean; schema: string }, 70 | ) { 71 | if (!production) { 72 | return db as unknown as DBUrl 73 | } 74 | const url = env.DB_URL || (envi(db.url) as string) 75 | if (!url) { 76 | logger.error('DB_URL is not set') 77 | return db as unknown as DBUrl 78 | } 79 | const database = url.slice(url.lastIndexOf('/') + 1) 80 | const regex = /(\w+):\/\/(\w+):(.*)@(.*):(\d+)\/(\w+)/ 81 | const found = url.match(regex) 82 | const dialect = found?.[1] || 'postgres' 83 | const username = found?.[2] || '' 84 | const password = found?.[3] || '' 85 | const host = found?.[4] || '' 86 | return { 87 | database, 88 | host, 89 | username, 90 | password, 91 | dialect, 92 | ssl: db.ssl, 93 | schema: db.schema, 94 | } as DBUrl 95 | } 96 | 97 | export function getConfig(): Config { 98 | dotenv.config({}) 99 | 100 | const production = !['development', 'test'].includes(env.NODE_ENV?.toLowerCase() || '') 101 | const serviceConfig = production ? appConfig.production : appConfig.development 102 | const { database, host, username, password, ssl, schema, dialect } = parseDatabaseConfig( 103 | production, 104 | serviceConfig.db, 105 | ) 106 | 107 | const DB_URL = `${dialect}://${username}:${password}@${host}/${database}` 108 | const osHost = os.hostname() 109 | const isLocalhost = osHost.includes('local') 110 | const port = Number(env.PORT) || Number(envi(serviceConfig.service.port)) 111 | const hostname = envi(serviceConfig.service.host) as string 112 | const protocol = envi(serviceConfig.service.protocol) as string 113 | const sslKey = envi(serviceConfig.service.sslKey) as string 114 | const sslCert = envi(serviceConfig.service.sslCert) as string 115 | 116 | return { 117 | trace: true, 118 | production, 119 | isLocalhost, 120 | hostname, 121 | protocol, 122 | backendBaseUrl: `${protocol}://${hostname}:${port}`, 123 | sslKey, 124 | sslCert, 125 | port, 126 | jsonLimit: env.JSON_LIMIT || '1mb', 127 | cors: { 128 | origin: env.CORS_ORIGIN || '*', 129 | }, 130 | db: { 131 | trace: false, 132 | sync: true, 133 | force: false, 134 | alter: true, 135 | database, 136 | host: host as string, 137 | url: DB_URL as string, 138 | schema: schema as string, 139 | ssl: env.DB_SSL === 'true' || (ssl as boolean), 140 | models: [], 141 | }, 142 | auth: { 143 | enabled: true, 144 | sync: true, 145 | trace: true, 146 | tokenSecret: env.TOKEN_SECRET || 'blank', 147 | redirectUrl: env.AUTH_REDIRECT_URL || 'http://localhost:3000/callback', 148 | tenant: env.AUTH_TENANT || '', 149 | domain: `${env.AUTH_TENANT}.auth0.com`, 150 | baseUrl: `https://${env.AUTH_TENANT}.auth0.com`, 151 | explorerAudience: `https://${env.AUTH_TENANT}.auth0.com/api/v2/`, 152 | explorerId: env.AUTH_EXPLORER_ID || '', 153 | explorerSecret: env.AUTH_EXPLORER_SECRET || '', 154 | clientAudience: env.AUTH_AUDIENCE || 'https://backend', 155 | clientId: env.AUTH_CLIENT_ID || '', 156 | clientSecret: env.AUTH_CLIENT_SECRET || '', 157 | ruleNamespace: 'https://', 158 | algorithm: 'RS256', 159 | }, 160 | swaggerSetup: { 161 | openapi: '3.0.0', 162 | info: { 163 | title: packageJson.name, 164 | description: packageJson.description, 165 | version: packageJson.version, 166 | }, 167 | servers: [ 168 | { 169 | url: `/`, 170 | }, 171 | ], 172 | basePath: '/docs', 173 | }, 174 | } 175 | } 176 | 177 | export function envi(val: unknown): unknown { 178 | return typeof val === 'string' && val.startsWith('$') ? env[val.slice(1)] : val 179 | } 180 | 181 | export function canStart() { 182 | logger.info(`****** READYNESS CHECK *******`) 183 | const p = config.port 184 | const d = config.db.database 185 | const result = !!p 186 | logger.info(`PRODUCTION: ${config.production}`) 187 | logger.info(`URL: ${config.backendBaseUrl}`) 188 | logger.info(`${p ? '✅' : '❌'} PORT: ${p ? p : 'ERROR - Missing'}`) 189 | logger.info( 190 | `${d ? '✅' : '❌'} DB: ${d ? `${config.db.database}@${config.db.host}` : 'ERROR - Missing'}`, 191 | ) 192 | logger.info(`**: ${result ? 'READY!' : 'HALT'}`) 193 | logger.info(`env.PORT: ${env.PORT} ⚡️`) 194 | return result 195 | } 196 | 197 | export const config: Config = getConfig() 198 | export default config 199 | -------------------------------------------------------------------------------- /src/shared/db/check.ts: -------------------------------------------------------------------------------- 1 | import config from '../config' 2 | import logger from '../logger' 3 | import Connection, { createDatabase } from '.' 4 | import { checkMigrations } from './migrator' 5 | 6 | export async function checkDatabase(syncOverride?: boolean): Promise { 7 | if (!Connection.initialized) { 8 | logger.error('DB Connection not initialized') 9 | return false 10 | } 11 | 12 | const syncModels = syncOverride ? syncOverride : config.db.sync 13 | 14 | try { 15 | logger.info('Connecting to database...') 16 | config.db.models = Connection.entities.map(m => m.name) 17 | await Connection.db.authenticate() 18 | logger.info( 19 | `Database: models: 20 | ${Connection.entities.map(a => a.name).join(', ')}`, 21 | ) 22 | if (syncModels) { 23 | await Connection.db.sync({ alter: config.db.alter, force: config.db.force }) 24 | } 25 | logger.info('Database: Connected') 26 | return true 27 | } catch (e: unknown) { 28 | const msg = (e as Error)?.message 29 | logger.error('Unable to connect to the database:', e) 30 | if (msg?.includes('does not exist')) { 31 | const result = await createDatabase() 32 | return result 33 | } 34 | if (msg?.includes('column')) { 35 | const result = await checkMigrations() 36 | return result 37 | } 38 | } 39 | return false 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/db/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Attributes, 3 | InitOptions, 4 | Model, 5 | ModelAttributeColumnOptions, 6 | ModelAttributes, 7 | ModelOptions, 8 | ModelStatic, 9 | Sequelize, 10 | } from 'sequelize' 11 | import { config } from '../config/index' 12 | import logger from '../logger' 13 | 14 | export const commonOptions: ModelOptions = { 15 | timestamps: true, 16 | underscored: true, 17 | } 18 | export interface Join { 19 | type: 'belongsTo' | 'hasOne' | 'hasMany' | 'belongsToMany' 20 | target: ModelStatic 21 | as?: string 22 | foreignKey?: string 23 | otherKey?: string 24 | through?: ModelStatic | string 25 | } 26 | 27 | export type EntityDefinition = { 28 | [key in keyof T]: ModelAttributeColumnOptions> 29 | } 30 | 31 | export interface EntityConfig { 32 | name: string 33 | attributes: ModelAttributes> 34 | roles?: string[] 35 | publicRead?: boolean 36 | publicWrite?: boolean 37 | model?: ModelStatic 38 | joins?: Join[] 39 | options?: Partial> 40 | onChanges?: (source?: string, model?: M) => Promise | void 41 | } 42 | 43 | export function sortEntities(a: EntityConfig, b: EntityConfig): number { 44 | if (a.name === 'user') { 45 | return -1 46 | } 47 | if (b.name === 'user') { 48 | return 1 49 | } 50 | const primaryKeysA = Object.keys(a.attributes).filter( 51 | key => (a.attributes[key] as ModelAttributeColumnOptions).primaryKey, 52 | ) 53 | const primaryKeysB = Object.keys(b.attributes).filter( 54 | key => (b.attributes[key] as ModelAttributeColumnOptions).primaryKey, 55 | ) 56 | if ( 57 | Object.keys(b.attributes).some( 58 | key => 59 | !(b.attributes[key] as ModelAttributeColumnOptions).primaryKey && 60 | primaryKeysA.includes(key), 61 | ) 62 | ) { 63 | return -1 64 | } else if ( 65 | Object.keys(a.attributes).some( 66 | key => 67 | !(a.attributes[key] as ModelAttributeColumnOptions).primaryKey && 68 | primaryKeysB.includes(key), 69 | ) 70 | ) { 71 | return 1 72 | } else { 73 | return 0 74 | } 75 | } 76 | 77 | export class Connection { 78 | public static entities: EntityConfig[] = [] 79 | public static db: Sequelize 80 | static initialized = false 81 | static init() { 82 | if (Connection.initialized) { 83 | logger.warn('Connection already initialized') 84 | return 85 | } 86 | const checkRuntime = config 87 | if (!checkRuntime) { 88 | throw new Error( 89 | 'Connection Class cannot read config, undefined variable - check for cyclic dependency', 90 | ) 91 | } 92 | if (!config.db.url || !config.db.database) { 93 | logger.error('DB URL not found, skipping DB init') 94 | return 95 | } 96 | if (config.db.trace) { 97 | logger.info(`Initializing DB...`) 98 | } 99 | try { 100 | Connection.db = new Sequelize(config.db.url, { 101 | logging: sql => (config.db.trace ? logger.info(`${sql}\n`) : undefined), 102 | ssl: !!config.db.ssl, 103 | dialectOptions: config.db.ssl 104 | ? { 105 | ssl: { 106 | require: true, 107 | rejectUnauthorized: false, 108 | }, 109 | } 110 | : {}, 111 | }) 112 | } catch (error) { 113 | logger.error('Error initializing DB', error) 114 | return 115 | } 116 | const sorted = Connection.entities.sort(sortEntities) 117 | Connection.initModels(sorted) 118 | Connection.initJoins(sorted) 119 | Connection.autoDetectJoins(sorted) 120 | Connection.initialized = true 121 | } 122 | 123 | static getAssociations(name: string) { 124 | const entity = Connection.entities.find(e => e.name == name) 125 | if (!entity) { 126 | throw new Error(`Entity ${name} not found`) 127 | } 128 | const primaryKeys = Object.keys(entity.attributes).filter( 129 | key => (entity.attributes[key] as ModelAttributeColumnOptions).primaryKey, 130 | ) 131 | const others = Connection.entities.filter(e => e.name !== name) 132 | const associations = others.filter(related => primaryKeys.some(key => related.attributes[key])) 133 | return associations 134 | } 135 | static initModels(sorted: EntityConfig[]) { 136 | for (const entity of sorted) { 137 | const scopedOptions = { 138 | ...commonOptions, 139 | ...entity.options, 140 | sequelize: Connection.db, 141 | modelName: entity.name, 142 | } 143 | if (!entity.model) { 144 | logger.error(`Entity without model: ${entity.name}`) 145 | continue 146 | } 147 | if (entity.model.name === 'model') { 148 | entity.model.init(entity.attributes, scopedOptions) 149 | } 150 | } 151 | } 152 | static initJoins(sorted: EntityConfig[]) { 153 | for (const entity of sorted) { 154 | if (!entity?.model) { 155 | return 156 | } 157 | const joins = entity.joins ?? [] 158 | for (const join of joins) { 159 | entity.model[join.type](join.target as ModelStatic, { 160 | foreignKey: join.foreignKey as string, 161 | otherKey: join.otherKey as string, 162 | through: join.through as ModelStatic, 163 | as: join.as as string, 164 | }) 165 | } 166 | } 167 | } 168 | 169 | /** 170 | * Detect joins based on fieldId naming convention 171 | * */ 172 | static autoDetectJoins(sorted: EntityConfig[]) { 173 | for (const entity of sorted) { 174 | if (!entity?.model) { 175 | return 176 | } 177 | const otherModels = Connection.entities.filter(e => e.name !== entity.name) 178 | for (const other of otherModels) { 179 | if (entity.model.associations[other.name]) { 180 | continue 181 | } 182 | const otherPrimaryKeys = Object.keys(other.attributes).filter( 183 | key => (other.attributes[key] as ModelAttributeColumnOptions).primaryKey, 184 | ) 185 | for (const otherPrimaryKey of otherPrimaryKeys) { 186 | const columnDef = entity.attributes[otherPrimaryKey] as ModelAttributeColumnOptions 187 | if (otherPrimaryKey.endsWith('Id') && columnDef && !columnDef.primaryKey) { 188 | entity.model.belongsTo(other.model as ModelStatic, { 189 | foreignKey: otherPrimaryKey, 190 | onDelete: 'CASCADE', 191 | }) 192 | other.model?.hasMany(entity.model, { 193 | foreignKey: otherPrimaryKey, 194 | }) 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | 202 | export async function createDatabase(): Promise { 203 | logger.info('Database does not exist, creating...') 204 | 205 | const rootUrl = config.db.url.replace(config.db.database, 'postgres') 206 | const root = new Sequelize(rootUrl) 207 | const qi = root.getQueryInterface() 208 | try { 209 | await qi.createDatabase(config.db.database) 210 | logger.info('Database created: ' + config.db.database) 211 | await Connection.db.sync() 212 | logger.info('Tables created') 213 | } catch (e: unknown) { 214 | logger.warn('Database creation failed: ' + JSON.stringify(e), e) 215 | return false 216 | } 217 | return true 218 | } 219 | 220 | /** 221 | * Deferred model registration for 222 | * sequelize and model-api endpoints 223 | * 224 | * @param name - table name 225 | * @param attributes - columns definitions 226 | * @param roles - restrict to roles like Admin 227 | * @param publicRead - Set GET and LIST public (no token needed) 228 | * @param publicWrite - POST, PUT, PATCH (no token needed) 229 | * @returns Typed model class reference with methods/utilities 230 | */ 231 | export function addModel({ 232 | name, 233 | attributes, 234 | joins, 235 | roles, 236 | publicRead, 237 | publicWrite, 238 | onChanges, 239 | options, 240 | }: EntityConfig>): ModelStatic> { 241 | const model = class extends Model {} 242 | const cfg: EntityConfig = { 243 | name, 244 | attributes, 245 | joins, 246 | roles, 247 | model, 248 | publicRead, 249 | publicWrite, 250 | onChanges, 251 | options, 252 | } 253 | Connection.entities.push(cfg) 254 | return model 255 | } 256 | 257 | export default Connection 258 | -------------------------------------------------------------------------------- /src/shared/db/migrator.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { SequelizeStorage, Umzug } from 'umzug' 3 | import { Connection } from '.' 4 | import path from 'path' 5 | import logger from '../../shared/logger' 6 | 7 | export const createMigrator = () => { 8 | if (!Connection.initialized) { 9 | Connection.init() 10 | } 11 | const sequelize = Connection.db 12 | const migrator = new Umzug({ 13 | create: { 14 | folder: path.resolve(__dirname, 'migrations'), 15 | template: filePath => [ 16 | [filePath, fs.readFileSync(path.resolve(__dirname, 'template.ts')).toString()], 17 | ], 18 | }, 19 | migrations: { 20 | glob: ['migrations/*.{ts,up.sql}', { cwd: __dirname }], 21 | resolve: params => { 22 | if (params.path && !params.path.endsWith('.sql')) { 23 | return Umzug.defaultResolver(params) 24 | } 25 | const { context: sequelize } = params 26 | const ppath = params.path as string 27 | return { 28 | name: params.name, 29 | up: async () => { 30 | const sql = fs.readFileSync(ppath).toString() 31 | return sequelize.query(sql) 32 | }, 33 | down: async () => { 34 | // Get the corresponding `.down.sql` file to undo this migration 35 | const sql = fs.readFileSync(ppath.replace('.up.sql', '.down.sql')).toString() 36 | return sequelize.query(sql) 37 | }, 38 | } 39 | }, 40 | }, 41 | context: sequelize, 42 | storage: new SequelizeStorage({ sequelize }), 43 | logger: console, 44 | }) 45 | 46 | if (require.main === module) { 47 | migrator.runAsCLI() 48 | } 49 | 50 | return migrator 51 | } 52 | export async function checkMigrations(): Promise { 53 | const migrator = createMigrator() 54 | const pending = await migrator.pending() 55 | if (pending.length > 0) { 56 | logger.info('Pending migrations', pending) 57 | try { 58 | const result = await migrator.up() 59 | logger.info('Migrations applied', result) 60 | } catch (e: unknown) { 61 | logger.error('Migration failed, reverting...', e) 62 | const down = await migrator.down() 63 | logger.info('Migrations reverted', down) 64 | return false 65 | } 66 | } 67 | return true 68 | } 69 | 70 | // CLI 71 | export default createMigrator() 72 | -------------------------------------------------------------------------------- /src/shared/db/template.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | 3 | // Sync() will create any new objects but will not alter existing ones 4 | // Migrations used for changes to existing objects to prevent data loss 5 | 6 | export const up = async ({ context }: { context: Sequelize }) => { 7 | const qi = context.getQueryInterface() 8 | const transaction = await context.transaction() 9 | try { 10 | // changes 11 | qi.changeColumn 12 | await transaction.commit() 13 | } catch (err) { 14 | await transaction.rollback() 15 | throw err 16 | } 17 | } 18 | 19 | export const down = async ({ context }: { context: Sequelize }) => { 20 | context.getQueryInterface 21 | //await context.getQueryInterface().removeX 22 | } 23 | -------------------------------------------------------------------------------- /src/shared/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import logger from './logger' 3 | import 'express-async-errors' 4 | 5 | export interface HttpErrorParams { 6 | message?: string 7 | name?: string 8 | data?: unknown 9 | status?: number 10 | stack?: string 11 | code?: string 12 | } 13 | 14 | export class HttpError extends Error { 15 | data: unknown 16 | status: number 17 | 18 | /** 19 | * Creates an API error instance. 20 | * @param {string} message - The error message, defaults to: 'API Error'. 21 | * @param {Object} data - Error object and/or additional data. 22 | * @param {number} status - The HTTP status code, defaults to: '500'. 23 | * @param {string} stack - Error stack. 24 | * @param {boolean} isPublic - Whether the message should be visible to user or not. 25 | */ 26 | constructor(params: HttpErrorParams = {}) { 27 | const { message = 'Internal server error', data, stack, status = 500 } = params 28 | super(message) 29 | this.message = message 30 | this.data = data 31 | this.status = status 32 | this.stack = stack 33 | Error.captureStackTrace(this, this.constructor) 34 | } 35 | } 36 | 37 | export class HttpNotFoundError extends HttpError { 38 | constructor(message = 'Not found error', data?: unknown) { 39 | super({ 40 | message, 41 | status: 404, 42 | data, 43 | }) 44 | } 45 | } 46 | 47 | export class HttpUnauthorizedError extends HttpError { 48 | constructor(message = 'Unauthorized: please sign in', data?: unknown) { 49 | super({ 50 | message, 51 | status: 401, 52 | data, 53 | }) 54 | } 55 | } 56 | 57 | export class HttpForbiddenError extends HttpError { 58 | constructor(message = 'Forbidden error', data?: unknown) { 59 | super({ 60 | message, 61 | status: 403, 62 | data, 63 | }) 64 | } 65 | } 66 | 67 | export class HttpBadRequestError extends HttpError { 68 | constructor(message = 'Bad request error', data?: unknown) { 69 | super({ 70 | message, 71 | status: 400, 72 | data, 73 | }) 74 | } 75 | } 76 | 77 | export function errorHandler( 78 | err: HttpError, 79 | req: Request, 80 | res: Response, 81 | next: NextFunction, 82 | ): void { 83 | const result = { 84 | status: err.status || 500, 85 | code: err.name, 86 | message: err.message, 87 | error: err.message, 88 | data: err.data, 89 | stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined, 90 | } 91 | 92 | logger.error( 93 | `Client with IP="${req.ip}" failed to complete request to="${req.method}" originating from="${req.originalUrl}". Status="${result.status}" Message="${err.message}"`, 94 | err, 95 | ) 96 | 97 | if (!res.headersSent) { 98 | res.status(result.status) 99 | } 100 | 101 | res.json(result) 102 | 103 | next(err) 104 | } 105 | -------------------------------------------------------------------------------- /src/shared/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp, cert, App } from 'firebase-admin/app' 2 | import { getSettingsAsync } from './settings' 3 | 4 | let firebaseApp: App | null = null 5 | export const getFirebaseApp = async (): Promise => { 6 | if (firebaseApp) { 7 | return firebaseApp 8 | } 9 | 10 | const settings = await getSettingsAsync(true) 11 | const serviceAccountKeyJson = settings.internal?.secrets?.google.serviceAccountJson 12 | 13 | if (!serviceAccountKeyJson) { 14 | throw new Error('To use firebase authentication you need to input your serviceAccountKey.json') 15 | } 16 | 17 | const serviceAccountObject = JSON.parse(serviceAccountKeyJson || '{}') 18 | const projectId = serviceAccountObject?.project_id 19 | const storageBucket = `${projectId}.appspot.com` 20 | const databaseURL = `https://${projectId}.firebaseio.com` 21 | const credential = serviceAccountObject ? cert(serviceAccountObject) : undefined 22 | firebaseApp = initializeApp({ 23 | credential, 24 | databaseURL, 25 | storageBucket 26 | }) 27 | return firebaseApp as App 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from 'express' 3 | import axios from 'axios' 4 | import winston from 'winston' 5 | import { config } from './config' 6 | import { decodeToken } from './auth' 7 | import { getRoutesFromApp } from './server' 8 | 9 | const format = winston.format.combine(winston.format.timestamp(), winston.format.simple()) 10 | const logger = winston.createLogger({ 11 | level: 'info', 12 | transports: [ 13 | new winston.transports.Console({ 14 | format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 15 | }), 16 | new winston.transports.File({ 17 | filename: '_error.log', 18 | level: 'error', 19 | format, 20 | }), 21 | new winston.transports.File({ 22 | filename: '_trace.log', 23 | format, 24 | }), 25 | ], 26 | }) 27 | 28 | export function endpointTracingMiddleware( 29 | req: express.Request, 30 | res: express.Response, 31 | next: express.NextFunction, 32 | ) { 33 | const methods = ['POST', 'PUT', 'PATCH', 'DELETE'] 34 | const endpoints = ['/cart'] 35 | if (!config.trace || !endpoints.includes(req.originalUrl) || !methods.includes(req.method)) { 36 | return next() 37 | } 38 | const token = req.headers.authorization?.replace('Bearer ', '') || '' 39 | if (config.auth.trace) { 40 | console.log('Token: ', token) 41 | } 42 | const decoded = decodeToken(token) 43 | console.log('\x1b[34m%s\x1b[0m', '******** INBOUND TRACE ** ') 44 | console.table({ 45 | method: req.method, 46 | endpoint: req.originalUrl, 47 | tokenOk: !!decoded, 48 | userId: decoded?.userId, 49 | }) 50 | if (req.body) { 51 | console.table(req.body) 52 | } 53 | next() 54 | } 55 | 56 | export function activateAxiosTrace() { 57 | axios.interceptors.request.use(req => { 58 | // orange console log 59 | console.log( 60 | '\x1b[33m%s\x1b[0m', 61 | '> OUTBOUND TRACE ** ', 62 | req.method?.toUpperCase() || 'Request', 63 | req.url, 64 | config.trace ? req.data : '', 65 | ) 66 | return req 67 | }) 68 | 69 | axios.interceptors.response.use(req => { 70 | console.log('> Response:', req.status, req.statusText, config.trace ? req.data : '') 71 | return req 72 | }) 73 | } 74 | 75 | export function printRouteSummary(app: express.Application) { 76 | if (!config.trace) { 77 | return 78 | } 79 | logger.info('******** ROUTE SUMMARY ********') 80 | const report = getRoutesFromApp(app) 81 | console.log(report) 82 | } 83 | 84 | export default logger 85 | -------------------------------------------------------------------------------- /src/shared/model-api/controller.ts: -------------------------------------------------------------------------------- 1 | import { FindOptions, Model, ModelStatic } from 'sequelize/types' 2 | import { MakeNullishOptional } from 'sequelize/types/utils' 3 | import { PagedResult } from '../types' 4 | import { GridPatchProps } from '../types' 5 | import { HttpNotFoundError } from '../errorHandler' 6 | import logger from '../logger' 7 | import sequelize from 'sequelize' 8 | import Connection from '../db' 9 | 10 | export async function list( 11 | model: ModelStatic>, 12 | options: FindOptions = { limit: 100, offset: 0, include: [] }, 13 | ): Promise> { 14 | const { count: total, rows } = await model.findAndCountAll({ 15 | nest: true, 16 | include: options.include || [], 17 | ...options, 18 | }) 19 | 20 | return { 21 | items: rows as unknown as T[], 22 | offset: options.offset || 0, 23 | limit: options.limit || 100, 24 | hasMore: total > (options.offset || 0) + (options.limit || 100), 25 | total, 26 | } 27 | } 28 | 29 | export async function getIfExists( 30 | model: ModelStatic>, 31 | id: string, 32 | ): Promise> { 33 | const item = await model.findByPk(id) 34 | if (!item) { 35 | throw new HttpNotFoundError(`Record with id ${id} not found`) 36 | } 37 | return item 38 | } 39 | 40 | export async function createOrUpdate( 41 | model: ModelStatic>, 42 | payload: object, 43 | ): Promise { 44 | const casted = payload as unknown as MakeNullishOptional 45 | try { 46 | const [item] = await model.upsert(casted) 47 | const entity = Connection.entities.find(e => e.name === model.name) 48 | if (entity?.onChanges) { 49 | entity.onChanges(`${entity.name}`, item) 50 | } 51 | return item.get() as unknown as T 52 | } catch (e: unknown) { 53 | const err = e as Error 54 | logger.error(`${model.name}.createOrUpdate(): ${err.message}`, err) 55 | throw new Error(err.message) 56 | } 57 | } 58 | 59 | export async function deleteIfExists( 60 | model: ModelStatic>, 61 | id: string, 62 | ): Promise { 63 | try { 64 | const item = await getIfExists(model, id) 65 | await item.destroy() 66 | return true 67 | } catch (e: unknown) { 68 | const err = e as Error 69 | logger.error(`${model.name}.deleteIfExists(): ${err.message}`, err) 70 | throw new Error(err.message) 71 | } 72 | } 73 | 74 | export async function gridPatch( 75 | model: ModelStatic>, 76 | payload: GridPatchProps, 77 | ): Promise { 78 | try { 79 | const item = await getIfExists(model, payload.id as string) 80 | item.update({ 81 | [payload.field]: payload.value, 82 | } as T) 83 | item.save() 84 | return item.get() 85 | } catch (e: unknown) { 86 | const err = e as Error 87 | logger.error(`${model.name}.gridPatch(): ${err.message}`, err) 88 | throw new Error(err.message) 89 | } 90 | } 91 | 92 | export async function gridDelete( 93 | model: ModelStatic>, 94 | payload: { ids: string[] }, 95 | ): Promise<{ deleted: number }> { 96 | try { 97 | const deleted = await model.destroy({ 98 | where: { 99 | [model.primaryKeyAttribute]: { 100 | [sequelize.Op.in]: payload.ids || [], 101 | }, 102 | } as sequelize.WhereOptions, 103 | }) 104 | return { deleted } 105 | } catch (e: unknown) { 106 | const err = e as Error 107 | logger.error(`${model.name}.gridPatch(): ${err.message}`, err) 108 | throw new Error(err.message) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/shared/model-api/routes.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { Model, ModelStatic, Op, Order } from 'sequelize' 3 | import Connection, { EntityConfig } from '../db' 4 | import { EnrichedRequest } from '../types' 5 | import { createOrUpdate, getIfExists, gridPatch, gridDelete, list } from './controller' 6 | import logger from '../logger' 7 | 8 | export interface ModelApiConfig { 9 | userIdColumn: string 10 | getAuthUserId(req: express.Request): string 11 | } 12 | 13 | export const modelApiConfig: ModelApiConfig = { 14 | userIdColumn: 'userId', 15 | getAuthUserId: req => (req as EnrichedRequest).auth?.uid 16 | } 17 | 18 | /** 19 | * Saves payload via upsert with userIdColumn set to auth.id 20 | * @param req 21 | * @param res 22 | * @returns 23 | */ 24 | export async function saveHandler(this: typeof Model, req: express.Request, res: express.Response) { 25 | if (!this) { 26 | throw new Error('this is not defined') 27 | } 28 | if (!req.body) { 29 | res.status(400).send('Request body is missing') 30 | } 31 | const model = this as ModelStatic 32 | const authId = modelApiConfig.getAuthUserId(req) 33 | if ( 34 | authId && 35 | Object.keys(model.getAttributes()).includes(modelApiConfig.userIdColumn) && 36 | !req.body[modelApiConfig.userIdColumn] 37 | ) { 38 | req.body[modelApiConfig.userIdColumn] = authId 39 | } 40 | const result = await createOrUpdate(model, req.body) 41 | res.json(result) 42 | } 43 | 44 | export async function gridPatchHandler( 45 | this: typeof Model, 46 | req: express.Request, 47 | res: express.Response 48 | ) { 49 | if (!this) { 50 | throw new Error('this is not defined') 51 | } 52 | if (!req.body) { 53 | res.status(400).send('Request body is missing') 54 | } 55 | const model = this as ModelStatic 56 | const authId = modelApiConfig.getAuthUserId(req) 57 | if ( 58 | authId && 59 | Object.keys(model.getAttributes()).includes(modelApiConfig.userIdColumn) && 60 | !req.body[modelApiConfig.userIdColumn] 61 | ) { 62 | req.body[modelApiConfig.userIdColumn] = authId 63 | } 64 | const result = await gridPatch(model, req.body) 65 | res.json(result) 66 | } 67 | 68 | export async function gridDeleteHandler( 69 | this: ModelStatic, 70 | req: express.Request, 71 | res: express.Response 72 | ) { 73 | if (!this) { 74 | throw new Error('this is not defined') 75 | } 76 | const model = this as ModelStatic 77 | const result = await gridDelete(model, req.body) 78 | res.json(result) 79 | } 80 | 81 | export async function getUserRelatedRecord(r: express.Request, model: ModelStatic) { 82 | const req = r as EnrichedRequest 83 | const authId = modelApiConfig.getAuthUserId(req) 84 | const instance = await getIfExists(model, req.params.id) 85 | if (authId && Object.keys(model.getAttributes()).includes(modelApiConfig.userIdColumn)) { 86 | const roles = req.config?.roles || [] 87 | const hasRole = roles ? roles?.every(r => roles.includes(r)) : false 88 | const item = instance.get() 89 | if (authId !== item[modelApiConfig.userIdColumn] && !hasRole) { 90 | throw new Error('Unauthorized access of another user data') 91 | } else { 92 | logger.warn( 93 | `user accesing another user ${model.tableName} ${authId} != 94 | ${item[modelApiConfig.userIdColumn]}` 95 | ) 96 | } 97 | } 98 | return instance 99 | } 100 | 101 | export async function deleteHandler( 102 | this: ModelStatic, 103 | req: express.Request, 104 | res: express.Response 105 | ) { 106 | if (!this) { 107 | throw new Error('this is not defined') 108 | } 109 | const model = this as ModelStatic 110 | const instance = await getUserRelatedRecord(req, model) 111 | instance.destroy() 112 | res.json({ success: true }) 113 | } 114 | 115 | export async function getHandler(this: typeof Model, req: express.Request, res: express.Response) { 116 | if (!this) { 117 | throw new Error('this is not defined') 118 | } 119 | const model = this as ModelStatic 120 | const result = await getIfExists(model, req.params.id) 121 | res.json(result) 122 | } 123 | 124 | const operatorMap: Record = { 125 | '!': Op.notLike, 126 | '%': Op.like, 127 | '>': Op.gt, 128 | '<': Op.lt, 129 | '[': Op.between 130 | } 131 | 132 | function checkForOperators(where: Record, field: string, value: string) { 133 | if (value && Object.keys(operatorMap).some(o => value.includes(o))) { 134 | const operator = Object.keys(operatorMap).find(o => value.includes(o)) as string 135 | const val = operator !== '%' ? value.replace(operator, '') : value 136 | if (operator === '[') { 137 | const [from, to] = val.split(',') 138 | where[field] = { [operatorMap[operator]]: [from, to] } 139 | } else { 140 | where[field] = { [operatorMap[operator]]: val } 141 | } 142 | } 143 | } 144 | 145 | /** 146 | * Get Listing 147 | * 148 | * Auto detects and filters table based on request.auth.userId === table.userId 149 | * Import and modify static modelApiConfig to customize 150 | * @param req 151 | * @param res 152 | */ 153 | export async function listHandler( 154 | this: ModelStatic, 155 | req: express.Request, 156 | res: express.Response 157 | ) { 158 | if (!this) { 159 | throw new Error('this is not defined') 160 | } 161 | const where: Record = {} 162 | const order: Order = [] 163 | const model = this as ModelStatic 164 | const fields = model.getAttributes() 165 | //filter by query params 1-1 to model attributes 166 | for (const field in req.query) { 167 | if (fields[field]) { 168 | const value = req.query[field] as string 169 | where[field] = value 170 | checkForOperators(where, field, value) 171 | } 172 | } 173 | //sorting 174 | if (req.query.orderBy) { 175 | const sortFields = (req.query.orderBy as string)?.split(',') 176 | for (const field of sortFields) { 177 | const direction = field.startsWith('-') ? 'DESC' : 'ASC' 178 | order.push([field.replace(/^-{1}/, ''), direction]) 179 | } 180 | } 181 | 182 | const include = req.query.include?.toString().split(',') || [] 183 | 184 | //userId filtering from authentication token 185 | const authId = modelApiConfig.getAuthUserId(req) 186 | if (authId && Object.keys(fields).includes(modelApiConfig.userIdColumn)) { 187 | where[modelApiConfig.userIdColumn] = authId 188 | } 189 | const limit = Number(req.query.limit || 100) 190 | const offset = Number(req.query.offset || 0) 191 | const result = await list(model, { where, limit, offset, order, include }) 192 | res.json(result) 193 | } 194 | 195 | function addRoutesForModel(router: express.Router, model: ModelStatic) { 196 | const prefix = model.name.toLowerCase() 197 | router.get(`/${prefix}`, listHandler.bind(model)) 198 | router.get(`/${prefix}/:id`, getHandler.bind(model)) 199 | router.post(`/${prefix}`, saveHandler.bind(model)) 200 | router.delete(`/${prefix}/:id`, deleteHandler.bind(model)) 201 | router.patch(`/${prefix}`, gridPatchHandler.bind(model)) 202 | router.delete(`/${prefix}`, gridDeleteHandler.bind(model)) 203 | } 204 | 205 | /** 206 | * Mounts the CRUD handlers for the given model using name as path, 207 | * ie: /v1/model.name/:id 208 | * 209 | * Might need options for any model to exclude certain methods/auth 210 | * @param models - array of sequelize models 211 | * @param router - express router 212 | **/ 213 | export function registerModelApiRoutes(entities: EntityConfig[], router: express.Router): void { 214 | if (!Connection.initialized) { 215 | return 216 | } 217 | for (const cfg of entities) { 218 | const model = cfg.model as ModelStatic 219 | if (model) { 220 | addRoutesForModel(router, model) 221 | } 222 | } 223 | 224 | const autoTables = entities 225 | .filter(e => e.joins?.find(j => typeof j.through === 'string')) 226 | .reduce((acc: Array, entity) => { 227 | return [...acc, ...(entity.joins?.map(j => j.through as string) ?? [])] 228 | }, []) 229 | 230 | for (const table of autoTables) { 231 | const model = Connection.db?.models[table] as ModelStatic 232 | if (model) { 233 | addRoutesForModel(router, model) 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/shared/model-api/swagger.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { Model, ModelStatic } from 'sequelize' 3 | import swaggerJsdoc, { OAS3Definition, Schema } from 'swagger-jsdoc' 4 | import config from '../config' 5 | import Connection, { EntityConfig } from '../db' 6 | import { getRoutesFromApp } from '../server' 7 | import fs from 'fs' 8 | import logger from '../logger' 9 | 10 | const conversions: Record = { 11 | INTEGER: 'number', 12 | BIGINT: 'number', 13 | FLOAT: 'number', 14 | DOUBLE: 'number', 15 | DECIMAL: 'number', 16 | NUMERIC: 'number', 17 | REAL: 'number', 18 | DATE: 'string', 19 | DATETIME: 'string', 20 | TIMESTAMP: 'string', 21 | TIME: 'string', 22 | CHAR: 'string', 23 | BOOLEAN: 'boolean', 24 | } as const 25 | 26 | const getConversion = (type: string): { type: string; items?: { type: string } } => { 27 | if (type.endsWith('[]')) { 28 | const arrayType = type.slice(0, type.indexOf('(')) 29 | return { type: 'array', items: { type: conversions[arrayType] || 'string' } } 30 | } 31 | const keys = Object.keys(conversions) 32 | const key = keys.find(key => type.startsWith(key)) 33 | return { 34 | type: key ? conversions[key] : 'string', 35 | } 36 | } 37 | 38 | export function getSchema(model: ModelStatic) { 39 | const excluded = ['createdAt', 'updatedAt', 'deletedAt'] 40 | const obj = model.name === 'model' ? {} : model.getAttributes() 41 | const columns = Object.entries(obj).filter(([name]) => !excluded.includes(name)) as [ 42 | [string, { type: string; allowNull: boolean }], 43 | ] 44 | const properties: { [key: string]: Schema } = {} 45 | for (const [name, attribute] of columns) { 46 | const { type, items } = getConversion(attribute.type.toString()) 47 | const definition: Schema = { type, required: !!attribute.allowNull, items } 48 | properties[name] = definition 49 | } 50 | return { 51 | type: 'object', 52 | properties, 53 | } 54 | } 55 | 56 | export function getPaths(model: typeof Model) { 57 | const tagName = model.name.toLowerCase() 58 | const tags = [tagName] 59 | const $ref = `#/components/schemas/${model.name}` 60 | const paths = { 61 | [`/${tagName}`]: { 62 | get: { 63 | summary: 'Get list', 64 | security: [{ BearerAuth: [] }], 65 | parameters: [ 66 | { 67 | name: 'include', 68 | in: 'query', 69 | }, 70 | ], 71 | responses: { 72 | '200': { 73 | content: { 74 | 'application/json': { 75 | schema: { 76 | type: 'array', 77 | items: { 78 | $ref, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | tags, 86 | }, 87 | post: { 88 | summary: 'Create or update an item', 89 | required: true, 90 | security: [{ BearerAuth: [] }], 91 | requestBody: { 92 | content: { 93 | 'application/json': { 94 | schema: { 95 | $ref, 96 | }, 97 | }, 98 | }, 99 | }, 100 | responses: { 101 | '200': { 102 | content: { 103 | 'application/json': { 104 | schema: { 105 | $ref, 106 | }, 107 | }, 108 | }, 109 | }, 110 | }, 111 | tags, 112 | }, 113 | }, 114 | [`/${tagName}/{id}`]: { 115 | get: { 116 | summary: 'Get one', 117 | security: [{ BearerAuth: [] }], 118 | parameters: [ 119 | { 120 | name: 'id', 121 | in: 'path', 122 | description: 'ID of the item to retrieve', 123 | required: true, 124 | schema: { 125 | type: 'string', 126 | }, 127 | }, 128 | ], 129 | responses: { 130 | '200': { 131 | content: { 132 | 'application/json': { 133 | schema: { 134 | $ref, 135 | }, 136 | }, 137 | }, 138 | }, 139 | }, 140 | tags, 141 | }, 142 | delete: { 143 | summary: 'Delete one', 144 | security: [{ BearerAuth: [] }], 145 | parameters: [ 146 | { 147 | name: 'id', 148 | in: 'path', 149 | description: 'ID of the item to delete', 150 | required: true, 151 | schema: { 152 | type: 'string', 153 | }, 154 | }, 155 | ], 156 | responses: { 157 | '200': { 158 | content: { 159 | 'application/json': { 160 | schema: { 161 | success: { type: 'boolean' }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | }, 167 | tags, 168 | }, 169 | }, 170 | } 171 | return paths 172 | } 173 | 174 | export function autoCompleteResponses(swaggerDoc: OAS3Definition) { 175 | for (const path in swaggerDoc.paths) { 176 | const def = swaggerDoc.paths[path] 177 | for (const method in def) { 178 | if (!def[method]) { 179 | def[method] = { summary: 'No summary' } 180 | } 181 | if (!def[method].responses) { 182 | def[method].responses = { 200: swaggerDoc.components?.responses?.Success } 183 | } 184 | } 185 | } 186 | } 187 | 188 | export function applyEntitiesToSwaggerDoc(entities: EntityConfig[], swaggerDoc: OAS3Definition) { 189 | if (!Connection.initialized) { 190 | return 191 | } 192 | for (const entity of entities) { 193 | const schema = getSchema(entity.model as ModelStatic) 194 | if (!swaggerDoc?.components?.schemas) { 195 | if (!swaggerDoc.components) { 196 | swaggerDoc.components = {} 197 | } 198 | swaggerDoc.components.schemas = {} 199 | } 200 | const existingSchema = swaggerDoc?.components?.schemas[entity.name] 201 | if (!existingSchema) { 202 | swaggerDoc.components.schemas[entity.name] = schema 203 | } 204 | if (!swaggerDoc.paths) { 205 | swaggerDoc.paths = {} 206 | } 207 | const paths = getPaths(entity.model as ModelStatic) 208 | for (const p in paths) { 209 | const existingPath = swaggerDoc.paths[p] 210 | if (!existingPath) { 211 | swaggerDoc.paths[p] = paths[p] 212 | } 213 | } 214 | 215 | // join auto tables, move to function 216 | const autoTables = 217 | entity.joins 218 | ?.filter(a => typeof a.through === 'string') 219 | .map(join => join.through as string) ?? [] 220 | for (const autoTable of autoTables) { 221 | const autoModel = Connection.db.models[autoTable] 222 | if (!autoModel) { 223 | logger.error(`Could not find auto model ${autoTable}`) 224 | continue 225 | } 226 | const schema = getSchema(autoModel) 227 | if (!swaggerDoc?.components?.schemas) { 228 | if (!swaggerDoc.components) { 229 | swaggerDoc.components = {} 230 | } 231 | swaggerDoc.components.schemas = {} 232 | } 233 | const existingSchema = swaggerDoc?.components?.schemas[autoModel.name] 234 | if (!existingSchema) { 235 | swaggerDoc.components.schemas[autoModel.name] = schema 236 | } 237 | if (!swaggerDoc.paths) { 238 | swaggerDoc.paths = {} 239 | } 240 | const paths = getPaths(autoModel) 241 | for (const p in paths) { 242 | const existingPath = swaggerDoc.paths[p] 243 | if (!existingPath) { 244 | swaggerDoc.paths[p] = paths[p] 245 | } 246 | } 247 | } 248 | } 249 | } 250 | 251 | export function getParameters(method: string, path: string) { 252 | const params = [...path.matchAll(/{\w+}/g)] 253 | const parameters = params.map(([name]) => ({ 254 | name: name.replace(/[{}]/g, ''), 255 | in: 'path', 256 | schema: { 257 | type: 'string', 258 | }, 259 | })) 260 | 261 | return ['post', 'put', 'patch', 'delete'].includes(method) 262 | ? { 263 | requestBody: { 264 | content: { 265 | 'application/json': { 266 | schema: { 267 | type: 'object', 268 | }, 269 | }, 270 | }, 271 | }, 272 | parameters, 273 | } 274 | : { 275 | parameters, 276 | } 277 | } 278 | 279 | export function applyRoutes(app: express.Application, swaggerDoc: OAS3Definition) { 280 | const paths = swaggerDoc.paths || {} 281 | 282 | const routes = getRoutesFromApp(app).filter(r => r.from === 'controller') 283 | for (const route of routes) { 284 | const rawPaths = Array.isArray(route.path) ? route.path : [route.path] 285 | const convertedPaths = rawPaths.map(p => 286 | p.replace(/:\w+/g, (match: string) => `{${match.substring(1)}}`), 287 | ) 288 | for (const path of convertedPaths) { 289 | if (!paths[path]) { 290 | paths[path] = {} 291 | } 292 | const def = paths[path] 293 | const tags = path.split('/')[1] ? [path.split('/')[1]] : [] 294 | const security = [{ BearerAuth: [] }] 295 | for (const method of route.methods) { 296 | if (!def[method]) { 297 | def[method] = { 298 | ...getParameters(method, path), 299 | summary: 'Detected', 300 | tags, 301 | security, 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } 308 | 309 | export function prepareSwagger(app: express.Application, entities: EntityConfig[]): OAS3Definition { 310 | const swaggerDev = swaggerJsdoc({ 311 | swaggerDefinition: config.swaggerSetup as OAS3Definition, 312 | apis: ['**/*/swagger.yaml', '**/*/index.ts'], 313 | }) as OAS3Definition 314 | 315 | const swaggerProd = fs.existsSync('swagger.json') 316 | ? JSON.parse(fs.readFileSync('swagger.json', 'utf8')) 317 | : {} 318 | 319 | const swaggerDoc = { ...swaggerDev, ...swaggerProd } 320 | 321 | applyRoutes(app, swaggerDoc) 322 | 323 | applyEntitiesToSwaggerDoc(entities, swaggerDoc) 324 | 325 | autoCompleteResponses(swaggerDoc) 326 | 327 | return swaggerDoc 328 | } 329 | -------------------------------------------------------------------------------- /src/shared/secrets.ts: -------------------------------------------------------------------------------- 1 | import { v1 } from '@google-cloud/secret-manager' 2 | import logger from './logger' 3 | 4 | export async function getSecrets() { 5 | logger.info(`Getting secrets from Secret Manager...`) 6 | const result = {} as { [key: string]: string } 7 | const client = new v1.SecretManagerServiceClient({ 8 | projectId: 'mstream-368503', 9 | }) 10 | const asyncList = client.listSecretsAsync() 11 | try { 12 | for await (const secret of asyncList) { 13 | const name = secret.name as string 14 | if (!name) { 15 | logger.error(`Secret name is missing`) 16 | continue 17 | } 18 | const [details] = await client.accessSecretVersion({ name }) 19 | logger.info(`Secret: ${name} = ${JSON.stringify(details)}`) 20 | if (!details.payload?.data) { 21 | result[name] = details.payload?.data as string 22 | } 23 | } 24 | } catch (error) { 25 | logger.error(`Error getting secrets: ${error}`) 26 | } 27 | return result 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { createServer } from 'http' 3 | import { createServer as createServerHttps } from 'https' 4 | import config from './config' 5 | export interface ExpressStack { 6 | name: string | string[] 7 | handle: { 8 | name: string 9 | stack: ExpressStack[] 10 | } 11 | regexp: string 12 | route: { 13 | path: string 14 | methods: { 15 | get?: boolean 16 | post?: boolean 17 | patch?: boolean 18 | } 19 | } 20 | } 21 | export interface End { 22 | path: string 23 | methods: string[] 24 | from: string 25 | } 26 | 27 | export function getRoutesFromApp(app: express.Application) { 28 | const composite = app._router.stack.find((s: ExpressStack) => s.name === 'router') as ExpressStack 29 | const recurse = (list: ExpressStack[], level = 0): End[] => { 30 | let result: End[] = [] 31 | for (const s of list) { 32 | const paths = Array.isArray(s.route?.path) ? s.route?.path : [s.route?.path] 33 | for (const path of paths) { 34 | if (path) { 35 | result.push({ 36 | path: s.route.path, 37 | methods: Object.keys(s.route.methods), 38 | from: level === 1 ? 'model-api' : 'controller', 39 | }) 40 | } else { 41 | result = [...result, ...recurse(s.handle.stack, level + 1)] 42 | } 43 | } 44 | } 45 | return result 46 | } 47 | return recurse([composite]) 48 | } 49 | 50 | // Homepage 51 | export function homepage(req: express.Request, res: express.Response) { 52 | const title = config.swaggerSetup.info?.title || 'Backend' 53 | res.send(`${title} 54 | 59 |
60 | ⚡️[server]: Backend is running on ${req.headers.host} with SwaggerUI Admin at ${config.swaggerSetup.basePath} 61 |
62 | `) 63 | } 64 | 65 | export function createServerService(app: express.Application) { 66 | const server = 67 | config.protocol === 'https' 68 | ? createServerHttps( 69 | { 70 | key: config.sslKey, 71 | cert: config.sslCert, 72 | }, 73 | app, 74 | ) 75 | : createServer(app) 76 | return server 77 | } 78 | -------------------------------------------------------------------------------- /src/shared/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthProviders, 3 | ClientConfig, 4 | Setting, 5 | SettingDataType, 6 | SettingState, 7 | } from '../shared/types' 8 | import config from './config' 9 | import Connection from './db' 10 | import logger from './logger' 11 | import { SettingModel } from './types' 12 | import { omit } from 'lodash' 13 | import NodeCache from 'node-cache' 14 | 15 | export const SettingsCache = new NodeCache({ stdTTL: 100, useClones: false }) 16 | const SETTINGS_CACHE_KEY = 'settings' 17 | 18 | export function getAuth0Settings(data: unknown) { 19 | const value = data as { [key: string]: string | boolean } 20 | const merged = { 21 | ...config.auth, 22 | ...value, 23 | enabled: value.enabled === true, 24 | sync: value.sync === true, 25 | domain: `${value.tenant}.auth0.com`, 26 | baseUrl: `https://${value.tenant}.auth0.com`, 27 | explorerAudience: `https://${value.tenant}.auth0.com/api/v2/`, 28 | } 29 | config.auth = merged 30 | } 31 | 32 | function setGoogle(result: SettingState) { 33 | const json = JSON.parse(result.internal?.secrets?.google?.serviceAccountJson || '{}') 34 | const projectId = json?.project_id 35 | if (result.google && !result.google.projectId) { 36 | result.google.projectId = projectId 37 | } 38 | } 39 | function setAuth(result: SettingState) { 40 | if (!result.system?.authProvider) { 41 | if (!result.system) { 42 | result.system = {} as SettingDataType 43 | } 44 | result.system.authProvider = AuthProviders.None 45 | } 46 | } 47 | 48 | export async function getSettingsAsync(freshNotCached = false): Promise { 49 | const cache = SettingsCache.get(SETTINGS_CACHE_KEY) 50 | if (cache && !freshNotCached) { 51 | logger.info(`Skipping settings load, cache hit...`) 52 | return cache as SettingState 53 | } 54 | 55 | if (!Connection.initialized) { 56 | throw new Error(`Skipping settings load, no connection...`) 57 | } 58 | logger.info(`Loading settings...`) 59 | const result = {} as SettingState 60 | try { 61 | const settings = (await SettingModel.findAll({ raw: true })) as unknown as Setting[] 62 | for (const setting of settings) { 63 | result[setting.name] = (setting as unknown as { data: SettingDataType }).data 64 | } 65 | } catch (e) { 66 | logger.error(e) 67 | } 68 | 69 | setGoogle(result) 70 | setAuth(result) 71 | 72 | SettingsCache.set(SETTINGS_CACHE_KEY, result) 73 | 74 | return result 75 | } 76 | export async function getClientSettings(isAdmin = false, force?: boolean): Promise { 77 | const allSettings = await getSettingsAsync(force) 78 | const admin = 79 | !config.production || isAdmin 80 | ? { 81 | models: config.db.models, 82 | } 83 | : undefined 84 | 85 | const settings = omit(allSettings, 'internal') 86 | const payload = { 87 | settings, 88 | admin, 89 | ready: !!settings.system, 90 | } 91 | return payload as ClientConfig 92 | } 93 | -------------------------------------------------------------------------------- /src/shared/socket/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { SocketHandler } from '..' 2 | 3 | export const userHandler: SocketHandler = (io, socket) => { 4 | socket.on('user:message', data => { 5 | console.log('user', data) 6 | }) 7 | } 8 | 9 | export default [userHandler] 10 | -------------------------------------------------------------------------------- /src/shared/socket/index.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http' 2 | import { Server as ServerHttps } from 'https' 3 | import { Server as SocketService, Socket } from 'socket.io' 4 | import { decodeToken } from '../auth' 5 | import logger from '../logger' 6 | import { createOrUpdate } from '../model-api/controller' 7 | import { UserActiveModel } from '../types' 8 | import handlers from './handlers' 9 | import { config } from '../config' 10 | import { getClientSettings } from '../settings' 11 | 12 | export type SocketHandler = (io: SocketService, socket: Socket) => void 13 | 14 | export let io: SocketService 15 | 16 | export function registerSocket(server: Server | ServerHttps): void { 17 | io = new SocketService(server, { 18 | cors: config.cors, 19 | }) 20 | const onConnection = (socket: Socket) => { 21 | handlers.forEach((handler: SocketHandler) => handler(io, socket)) 22 | 23 | logger.info(`⚡️ [socket]: New connection: ${socket.id}`) 24 | 25 | socket.send('Helo', { 26 | notifications: ['Hi!'], 27 | }) 28 | 29 | const decoded = decodeToken(socket.handshake.auth.token) 30 | logger.info('decoded' + JSON.stringify(decoded)) 31 | createOrUpdate(UserActiveModel, { 32 | socketId: socket.id, 33 | userId: decoded?.userId, 34 | ip: socket.handshake.address, 35 | userAgent: socket.handshake.headers['user-agent'], 36 | }) 37 | 38 | socket.on('disconnect', () => { 39 | logger.info('- socket disconnected: ' + socket.id) 40 | UserActiveModel.destroy({ 41 | where: { 42 | socketId: socket.id, 43 | }, 44 | }) 45 | }) 46 | } 47 | io.on('connection', onConnection) 48 | } 49 | 50 | export async function broadcastChange(eventName: string, data: unknown): Promise { 51 | // const sockets = await io.except(userId).fetchSockets() 52 | // io.except(userId).emit('config', { data }) 53 | io.emit(eventName, { data }) 54 | } 55 | 56 | export async function notifyChange(eventName: string): Promise { 57 | io.emit(eventName) 58 | } 59 | 60 | export async function sendConfig(): Promise { 61 | const payload = await getClientSettings() 62 | io.emit('config', { ...payload }) 63 | } 64 | -------------------------------------------------------------------------------- /src/shared/trace.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import express from 'express' 3 | import axios from 'axios' 4 | import { config } from './config' 5 | import { decodeToken } from './auth' 6 | import { getRoutesFromApp } from './server' 7 | import logger from './logger' 8 | 9 | export function endpointTracingMiddleware( 10 | req: express.Request, 11 | res: express.Response, 12 | next: express.NextFunction, 13 | ) { 14 | const methods = ['POST', 'PUT', 'PATCH', 'DELETE'] 15 | const endpoints = ['/cart'] 16 | if (!config.trace || !endpoints.includes(req.originalUrl) || !methods.includes(req.method)) { 17 | return next() 18 | } 19 | const token = req.headers.authorization?.replace('Bearer ', '') || '' 20 | if (config.auth.trace) { 21 | console.log('Token: ', token) 22 | } 23 | const decoded = decodeToken(token) 24 | console.log('\x1b[34m%s\x1b[0m', '******** INBOUND TRACE ** ') 25 | console.table({ 26 | method: req.method, 27 | endpoint: req.originalUrl, 28 | tokenOk: !!decoded, 29 | userId: decoded?.uid, 30 | }) 31 | if (req.body) { 32 | console.table(req.body) 33 | } 34 | next() 35 | } 36 | 37 | export function activateAxiosTrace() { 38 | axios.interceptors.request.use(req => { 39 | // orange console log 40 | console.log( 41 | '\x1b[33m%s\x1b[0m', 42 | '> OUTBOUND TRACE ** ', 43 | req.method?.toUpperCase() || 'Request', 44 | req.url, 45 | config.trace ? req.data : '', 46 | ) 47 | return req 48 | }) 49 | 50 | axios.interceptors.response.use(req => { 51 | console.log('> Response:', req.status, req.statusText, config.trace ? req.data : '') 52 | return req 53 | }) 54 | } 55 | 56 | export function printRouteSummary(app: express.Application) { 57 | if (!config.trace) { 58 | return 59 | } 60 | logger.info('******** ROUTE SUMMARY ********') 61 | const report = getRoutesFromApp(app).filter(r => r.from !== 'model-api') 62 | console.log(report) 63 | } 64 | 65 | export default logger 66 | -------------------------------------------------------------------------------- /src/shared/types/auth.ts: -------------------------------------------------------------------------------- 1 | export interface Jwt { 2 | [key: string]: unknown 3 | iss?: string | undefined 4 | sub?: string | undefined 5 | aud?: string | string[] | undefined 6 | exp?: number | undefined 7 | nbf?: number | undefined 8 | iat?: number | undefined 9 | jti?: string | undefined 10 | } 11 | 12 | export interface AppAccessToken extends Jwt { 13 | uid: string 14 | roles: string[] 15 | memberships: string[] 16 | claims: { 17 | [key: string]: unknown 18 | } 19 | } 20 | 21 | export interface IdentityToken extends Jwt { 22 | picture?: string | undefined 23 | email: string 24 | name: string 25 | given_name: string 26 | family_name: string 27 | } 28 | 29 | export interface oAuthError { 30 | error?: string 31 | error_description?: string 32 | status?: number 33 | } 34 | 35 | export interface oAuthResponse extends oAuthError { 36 | access_token?: string 37 | id_token?: string 38 | scope?: string 39 | expires_in?: number 40 | token_type?: string 41 | } 42 | 43 | export interface oAuthRegistered extends oAuthError { 44 | _id?: string 45 | email?: string 46 | family_name?: string 47 | given_name?: string 48 | email_verified?: boolean 49 | } 50 | 51 | export type oAuthInputs = { [key: string]: string } & { 52 | email?: string 53 | password?: string 54 | idToken?: string 55 | firstName?: string 56 | lastName?: string 57 | uid?: string 58 | } 59 | -------------------------------------------------------------------------------- /src/shared/types/blab.ts: -------------------------------------------------------------------------------- 1 | export const BlabTypes = { 2 | Text: 'text', 3 | Image: 'image', 4 | Video: 'video', 5 | Audio: 'audio', 6 | File: 'file' 7 | } as const 8 | 9 | export type BlabType = typeof BlabTypes[keyof typeof BlabTypes] 10 | 11 | export interface Blab { 12 | BlabId?: string 13 | BlabType?: BlabType 14 | Blab?: string 15 | BlabName?: string 16 | BlabDescription?: string 17 | BlabUrl?: string 18 | BlabSize?: number 19 | BlabWidth?: number 20 | BlabHeight?: number 21 | BlabDuration?: number 22 | BlabEncoding?: string 23 | BlabMimeType?: string 24 | BlabExtension?: string 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/types/cart.ts: -------------------------------------------------------------------------------- 1 | import { Drawing } from './drawing' 2 | import { Price, Product } from './product' 3 | 4 | export const CartType = { 5 | PRODUCT: 'product', 6 | SUBSCRIPTION: 'subscription', 7 | DRAWING: 'drawing', 8 | TOKENS: 'tokens' 9 | } as const 10 | 11 | export type CartType = typeof CartType[keyof typeof CartType] 12 | 13 | export interface Cart { 14 | cartId: string 15 | userId: string 16 | quantity: number 17 | cartType?: CartType 18 | drawingId?: string 19 | productId?: string 20 | priceId?: string 21 | drawing?: Drawing 22 | product?: Partial 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/types/category.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Item } from '.' 2 | 3 | export interface Category extends Entity { 4 | categoryId?: string 5 | title?: string 6 | imageUrl: string 7 | urlName?: string 8 | items?: Item[] 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/types/chapter.ts: -------------------------------------------------------------------------------- 1 | import { Blab } from './blab' 2 | 3 | export interface Chapter { 4 | chapterId?: string 5 | titleId?: number 6 | chapterNumber?: number 7 | title?: string 8 | chapter?: string 9 | blabs?: Blab[] 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/types/drawing.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '.' 2 | import { User } from './user' 3 | 4 | export enum ActionType { 5 | Open = 0, 6 | Close = 1, 7 | Stroke = 2, 8 | } 9 | 10 | /** 11 | * Reducing space as much as possible 12 | * 13 | * c: color 14 | * w: width/size 15 | * ts: unix timestamp 16 | */ 17 | // better space would be, mini serializer needed 18 | // JSON.stringify([...Object.values({ x, y, t, w, st, ts })]) 19 | export interface DrawAction { 20 | t: ActionType 21 | x?: number 22 | y?: number 23 | c?: string 24 | w?: number 25 | ts?: number 26 | } 27 | 28 | export interface Drawing extends Entity { 29 | drawingId?: string 30 | userId?: string 31 | name: string 32 | history: DrawAction[] 33 | thumbnail?: string 34 | private?: boolean 35 | sell?: boolean 36 | price?: number 37 | hits?: number 38 | user?: User 39 | } 40 | 41 | // might be better to add up all open closes hmm 42 | export function getTimeSpent(d: Drawing): number { 43 | if (!d?.history || d.history?.length < 2) { 44 | return 0 45 | } 46 | const first = d.history[0].ts as number 47 | const last = d.history[d.history.length - 1].ts as number 48 | const millisecs = last - first 49 | return millisecs 50 | } 51 | 52 | export function getDuration(d: Drawing) { 53 | const secs = Math.round(getTimeSpent(d) / 1000) 54 | const mins = Math.round(secs / 60) 55 | const hours = Math.round(mins / 60) 56 | const rem = secs % 60 57 | return `${hours}h:${mins}m:${rem}s` 58 | } 59 | -------------------------------------------------------------------------------- /src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { EntityConfig } from '../db' 3 | import { Order } from './order' 4 | import { Wallet } from './wallet' 5 | import { AppAccessToken } from './auth' 6 | 7 | export * from './cart' 8 | export * from './drawing' 9 | export * from './order' 10 | export * from './setting' 11 | export * from './user' 12 | export * from './subscription' 13 | export * from './product' 14 | export * from './wallet' 15 | export * from './auth' 16 | export * from './item' 17 | export * from './category' 18 | export * from './models' 19 | 20 | /** 21 | * Common Model Options 22 | * ie: Timestamps 23 | */ 24 | export interface Entity { 25 | createdAt?: Date 26 | updatedAt?: Date 27 | deletedAt?: Date 28 | } 29 | 30 | export interface PagedResult { 31 | items: T[] 32 | offset: number 33 | limit: number 34 | hasMore: boolean 35 | total: number 36 | } 37 | 38 | export interface GridPatchProps { 39 | id: string | number 40 | field: string 41 | value: unknown 42 | } 43 | 44 | export interface CheckoutRequest { 45 | ids: { cartId?: string; productId?: string; drawingId?: string }[] 46 | intent?: { amount: number; currency: string } 47 | confirmation?: string 48 | shippingAddressId?: string 49 | paymentSource?: string 50 | } 51 | 52 | export interface CheckoutResponse { 53 | order: Order 54 | error: string 55 | wallet?: Wallet 56 | } 57 | 58 | export type EnrichedRequest = express.Request & { 59 | auth: AppAccessToken 60 | config?: EntityConfig 61 | } 62 | -------------------------------------------------------------------------------- /src/shared/types/item.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '.' 2 | import { Blab } from './blab' 3 | import { Chapter } from './chapter' 4 | 5 | export interface Item extends Entity { 6 | itemId?: string 7 | title?: string 8 | urlName?: string 9 | subscriptions?: string[] 10 | tokens?: number 11 | price?: number 12 | currency?: string 13 | inventory?: number 14 | blabs?: Blab[] 15 | paywall?: boolean 16 | tags?: string[] 17 | chapters?: Chapter[] 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/types/models/cart.ts: -------------------------------------------------------------------------------- 1 | import { addModel } from '../../db' 2 | import { DataTypes } from 'sequelize' 3 | import { DrawingModel } from './drawing' 4 | import { ProductModel } from './product' 5 | import { Cart } from '..' 6 | 7 | export const CartAttributes = { 8 | cartId: { 9 | primaryKey: true, 10 | type: DataTypes.UUID, 11 | defaultValue: DataTypes.UUIDV4, 12 | }, 13 | userId: { 14 | type: DataTypes.UUID, 15 | }, 16 | cartType: { 17 | type: DataTypes.STRING, 18 | }, 19 | productId: { 20 | type: DataTypes.STRING, 21 | }, 22 | priceId: { 23 | type: DataTypes.STRING, 24 | }, 25 | drawingId: { 26 | type: DataTypes.UUID, 27 | }, 28 | quantity: { 29 | type: DataTypes.INTEGER, 30 | }, 31 | } 32 | 33 | export const CartModel = addModel({ 34 | name: 'cart', 35 | attributes: CartAttributes, 36 | joins: [ 37 | { 38 | type: 'belongsTo', 39 | target: DrawingModel, 40 | foreignKey: 'drawingId', 41 | as: 'drawing', 42 | }, 43 | { 44 | type: 'belongsTo', 45 | target: ProductModel, 46 | foreignKey: 'productId', 47 | as: 'product', 48 | }, 49 | ], 50 | }) 51 | 52 | export default CartModel 53 | -------------------------------------------------------------------------------- /src/shared/types/models/category.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '..' 2 | import { DataTypes } from 'sequelize' 3 | import { EntityDefinition } from 'src/shared/db' 4 | import { addModel } from 'src/shared/db' 5 | 6 | export const CategoryDefinition: EntityDefinition = { 7 | categoryId: { 8 | type: DataTypes.STRING, 9 | primaryKey: true, 10 | }, 11 | title: { 12 | type: DataTypes.STRING, 13 | }, 14 | imageUrl: { 15 | type: DataTypes.STRING, 16 | }, 17 | } 18 | 19 | export const CategoryModel = addModel({ 20 | name: 'category', 21 | attributes: CategoryDefinition, 22 | }) 23 | -------------------------------------------------------------------------------- /src/shared/types/models/drawing.ts: -------------------------------------------------------------------------------- 1 | import { addModel } from '../../db/index' 2 | import { DataTypes } from 'sequelize' 3 | import { Drawing } from '..' 4 | 5 | export const DrawingModel = addModel({ 6 | name: 'drawing', 7 | attributes: { 8 | drawingId: { 9 | type: DataTypes.UUID, 10 | primaryKey: true, 11 | defaultValue: DataTypes.UUIDV4, 12 | }, 13 | userId: { 14 | type: DataTypes.UUID, 15 | }, 16 | name: { 17 | type: DataTypes.STRING, 18 | }, 19 | history: { 20 | type: DataTypes.JSONB, 21 | }, 22 | thumbnail: { 23 | type: DataTypes.TEXT, 24 | }, 25 | private: { 26 | type: DataTypes.BOOLEAN, 27 | }, 28 | sell: { 29 | type: DataTypes.BOOLEAN, 30 | }, 31 | price: { 32 | type: DataTypes.DECIMAL(10, 2), 33 | }, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /src/shared/types/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cart' 2 | export * from './drawing' 3 | export * from './order' 4 | export * from './setting' 5 | export * from './user' 6 | export * from './subscription' 7 | export * from './product' 8 | export * from './wallet' 9 | export * from './item' 10 | export * from './category' 11 | -------------------------------------------------------------------------------- /src/shared/types/models/item.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize' 2 | import { EntityDefinition, Join } from '../../db' 3 | import { Item } from '..' 4 | import { CategoryModel } from './category' 5 | import { addModel } from '../../db' 6 | 7 | export const ItemDefinition: EntityDefinition = { 8 | itemId: { 9 | type: DataTypes.UUID, 10 | primaryKey: true, 11 | }, 12 | title: { 13 | type: DataTypes.STRING, 14 | }, 15 | urlName: { 16 | type: DataTypes.STRING, 17 | }, 18 | subscriptions: { 19 | type: DataTypes.ARRAY(DataTypes.STRING), 20 | }, 21 | tokens: { 22 | type: DataTypes.INTEGER, 23 | }, 24 | price: { 25 | type: DataTypes.DECIMAL(10, 2), 26 | }, 27 | currency: { 28 | type: DataTypes.STRING, 29 | }, 30 | inventory: { 31 | type: DataTypes.INTEGER, 32 | }, 33 | paywall: { 34 | type: DataTypes.BOOLEAN, 35 | }, 36 | } 37 | 38 | const joins: Join[] = [ 39 | { 40 | target: CategoryModel, 41 | through: 'itemCategory', 42 | type: 'belongsToMany', 43 | foreignKey: 'itemId', 44 | otherKey: 'categoryId', 45 | }, 46 | ] 47 | 48 | export const ItemModel = addModel({ 49 | name: 'item', 50 | attributes: ItemDefinition, 51 | joins, 52 | }) 53 | -------------------------------------------------------------------------------- /src/shared/types/models/order.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize' 2 | import { addModel } from '../../db' 3 | import { Order, OrderItem } from '..' 4 | 5 | export const OrderModel = addModel({ 6 | name: 'order', 7 | attributes: { 8 | orderId: { 9 | type: DataTypes.UUID, 10 | primaryKey: true, 11 | defaultValue: DataTypes.UUIDV4, 12 | }, 13 | userId: { 14 | type: DataTypes.UUID, 15 | }, 16 | status: { 17 | type: DataTypes.STRING, 18 | }, 19 | total: { 20 | type: DataTypes.DECIMAL(10, 2), 21 | }, 22 | }, 23 | }) 24 | 25 | export const OrderItemModel = addModel({ 26 | name: 'orderItem', 27 | attributes: { 28 | orderItemId: { 29 | type: DataTypes.UUID, 30 | primaryKey: true, 31 | defaultValue: DataTypes.UUIDV4, 32 | }, 33 | orderId: { 34 | type: DataTypes.UUID, 35 | }, 36 | drawingId: { 37 | type: DataTypes.UUID, 38 | }, 39 | productId: { 40 | type: DataTypes.STRING, 41 | }, 42 | priceId: { 43 | type: DataTypes.STRING, 44 | }, 45 | paid: { 46 | type: DataTypes.DECIMAL(10, 2), 47 | }, 48 | quantity: { 49 | type: DataTypes.INTEGER, 50 | }, 51 | tokens: { 52 | type: DataTypes.INTEGER, 53 | }, 54 | type: { 55 | type: DataTypes.STRING, 56 | }, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /src/shared/types/models/product.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '..' 2 | import { DataTypes } from 'sequelize' 3 | import { addModel } from '../../db' 4 | 5 | export const ProductDefinition = { 6 | productId: { 7 | type: DataTypes.STRING, 8 | primaryKey: true, 9 | }, 10 | title: { 11 | type: DataTypes.STRING, 12 | required: true, 13 | }, 14 | description: { 15 | type: DataTypes.STRING, 16 | }, 17 | imageUrl: { 18 | type: DataTypes.STRING, 19 | }, 20 | images: { 21 | type: DataTypes.ARRAY(DataTypes.STRING), 22 | }, 23 | keywords: { 24 | type: DataTypes.STRING, 25 | }, 26 | prices: { 27 | type: DataTypes.JSONB, 28 | }, 29 | shippable: { 30 | type: DataTypes.BOOLEAN, 31 | defaultValue: false, 32 | }, 33 | } 34 | 35 | export const ProductModel = addModel({ name: 'product', attributes: ProductDefinition }) 36 | -------------------------------------------------------------------------------- /src/shared/types/models/setting.ts: -------------------------------------------------------------------------------- 1 | import { Setting } from '..' 2 | import { DataTypes } from 'sequelize' 3 | import { sendConfig } from '../../../shared/socket' 4 | import { addModel } from '../../db' 5 | 6 | export const SettingModel = addModel({ 7 | name: 'setting', 8 | attributes: { 9 | name: { 10 | primaryKey: true, 11 | type: DataTypes.STRING, 12 | }, 13 | data: { 14 | type: DataTypes.JSONB, 15 | }, 16 | }, 17 | joins: [], 18 | roles: ['admin'], 19 | publicRead: false, 20 | publicWrite: false, 21 | onChanges: sendConfig, 22 | }) 23 | 24 | export default SettingModel 25 | -------------------------------------------------------------------------------- /src/shared/types/models/subscription.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize' 2 | import { addModel } from '../../db' 3 | import { Subscription } from '..' 4 | 5 | export const SubscriptionModel = addModel({ 6 | name: 'subscription', 7 | attributes: { 8 | subscriptionId: { 9 | type: DataTypes.UUID, 10 | primaryKey: true, 11 | defaultValue: DataTypes.UUIDV4, 12 | }, 13 | userId: { 14 | type: DataTypes.UUID, 15 | }, 16 | orderId: { 17 | type: DataTypes.UUID, 18 | }, 19 | priceId: { 20 | type: DataTypes.STRING, 21 | }, 22 | status: { 23 | type: DataTypes.STRING, 24 | }, 25 | title: { 26 | type: DataTypes.STRING, 27 | }, 28 | canceledAt: { 29 | type: DataTypes.DATE, 30 | }, 31 | cancelationReason: { 32 | type: DataTypes.STRING, 33 | }, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /src/shared/types/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Address, User, UserActive } from '..' 2 | import { DataTypes } from 'sequelize' 3 | import { addModel } from '../../db' 4 | 5 | export const UserAttributes = { 6 | userId: { 7 | type: DataTypes.UUID, 8 | primaryKey: true, 9 | defaultValue: DataTypes.UUIDV4, 10 | }, 11 | firstName: { 12 | type: DataTypes.STRING, 13 | }, 14 | lastName: { 15 | type: DataTypes.STRING, 16 | }, 17 | email: { 18 | type: DataTypes.STRING, 19 | }, 20 | picture: { 21 | type: DataTypes.STRING, 22 | }, 23 | roles: { 24 | type: DataTypes.ARRAY(DataTypes.STRING), 25 | }, 26 | } 27 | 28 | export const UserModel = addModel({ name: 'user', attributes: UserAttributes }) 29 | 30 | export const UserActiveModel = addModel({ 31 | name: 'user_active', 32 | attributes: { 33 | socketId: { 34 | type: DataTypes.STRING, 35 | primaryKey: true, 36 | }, 37 | userId: { 38 | type: DataTypes.UUID, 39 | }, 40 | ip: { 41 | type: DataTypes.STRING, 42 | }, 43 | userAgent: { 44 | type: DataTypes.STRING, 45 | }, 46 | }, 47 | }) 48 | 49 | export const AddressModel = addModel
({ 50 | name: 'address', 51 | attributes: { 52 | addressId: { 53 | type: DataTypes.UUID, 54 | primaryKey: true, 55 | defaultValue: DataTypes.UUIDV4, 56 | }, 57 | userId: { 58 | type: DataTypes.UUID, 59 | }, 60 | name: { 61 | type: DataTypes.STRING, 62 | }, 63 | address1: { 64 | type: DataTypes.STRING, 65 | }, 66 | address2: { 67 | type: DataTypes.STRING, 68 | }, 69 | city: { 70 | type: DataTypes.STRING, 71 | }, 72 | state: { 73 | type: DataTypes.STRING, 74 | }, 75 | zip: { 76 | type: DataTypes.STRING, 77 | }, 78 | country: { 79 | type: DataTypes.STRING, 80 | }, 81 | phone: { 82 | type: DataTypes.STRING, 83 | }, 84 | default: { 85 | type: DataTypes.BOOLEAN, 86 | }, 87 | }, 88 | }) 89 | -------------------------------------------------------------------------------- /src/shared/types/models/wallet.ts: -------------------------------------------------------------------------------- 1 | import { Wallet, WalletTransaction } from '..' 2 | import { DataTypes } from 'sequelize' 3 | import { addModel } from 'src/shared/db' 4 | 5 | export const WalletModel = addModel({ 6 | name: 'wallet', 7 | attributes: { 8 | walletId: { 9 | type: DataTypes.UUID, 10 | primaryKey: true, 11 | }, 12 | balance: { 13 | type: DataTypes.DECIMAL(10, 2), 14 | }, 15 | currency: { 16 | type: DataTypes.STRING, 17 | }, 18 | status: { 19 | type: DataTypes.STRING, 20 | }, 21 | }, 22 | }) 23 | 24 | export const WalletTransactionModel = addModel({ 25 | name: 'walletTransaction', 26 | attributes: { 27 | transactionId: { 28 | type: DataTypes.UUID, 29 | primaryKey: true, 30 | }, 31 | userId: { 32 | type: DataTypes.UUID, 33 | }, 34 | orderId: { 35 | type: DataTypes.UUID, 36 | }, 37 | amount: { 38 | type: DataTypes.STRING, 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /src/shared/types/order.ts: -------------------------------------------------------------------------------- 1 | import { CartType, Entity, Price, Product, Subscription } from '.' 2 | import { User } from './user' 3 | import { Drawing } from './drawing' 4 | 5 | export const OrderStatus = { 6 | Pending: 'pending', 7 | Paid: 'paid', 8 | Shipped: 'shipped', 9 | Delivered: 'delivered', 10 | Cancelled: 'cancelled' 11 | } as const 12 | 13 | export type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus] 14 | 15 | export interface OrderItem extends Entity { 16 | orderItemId?: string 17 | orderId?: string 18 | drawingId?: string 19 | productId?: string 20 | priceId?: string 21 | paid?: number 22 | quantity?: number 23 | tokens?: number 24 | drawing?: Drawing 25 | type?: CartType 26 | product?: Partial 27 | } 28 | 29 | export interface Order extends Entity { 30 | orderId?: string 31 | userId?: string 32 | billingAddressId?: string 33 | shippingAddressId?: string 34 | 35 | paymentType?: PaymentType 36 | paymentIntentId?: string 37 | payment?: { 38 | [key: string]: unknown 39 | } 40 | total?: number 41 | status?: OrderStatus 42 | OrderItems?: OrderItem[] 43 | user?: User 44 | subscription?: Subscription 45 | } 46 | 47 | export const PaymentTypes = { 48 | Subscription: 'subscription', 49 | OneTime: 'onetime' 50 | } as const 51 | 52 | export type PaymentType = typeof PaymentTypes[keyof typeof PaymentTypes] 53 | 54 | export interface Payment extends Entity { 55 | paymentId: string 56 | userId: string 57 | orderId: string 58 | amount: number 59 | currency: string 60 | status?: string 61 | } 62 | 63 | export const CaptureStatus = { 64 | Successful: 'completed', 65 | Pending: 'pending', 66 | Failed: 'failed', 67 | Created: 'created' 68 | } as const 69 | 70 | export const StripeToCaptureStatusMap = { 71 | canceled: CaptureStatus.Failed, 72 | succeeded: CaptureStatus.Successful, 73 | processing: CaptureStatus.Pending, 74 | requires_action: CaptureStatus.Pending, 75 | requires_capture: CaptureStatus.Pending, 76 | requires_confirmation: CaptureStatus.Pending, 77 | requires_payment_method: CaptureStatus.Pending, 78 | unknown: CaptureStatus.Failed 79 | } as const 80 | -------------------------------------------------------------------------------- /src/shared/types/page.ts: -------------------------------------------------------------------------------- 1 | import { Blab } from './blab' 2 | 3 | export interface Page { 4 | pageId?: string 5 | path?: string 6 | title?: string 7 | roles?: string[] 8 | blabs?: Blab[] 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/types/product.ts: -------------------------------------------------------------------------------- 1 | export interface Price { 2 | id: string 3 | amount: number 4 | currency: string 5 | interval?: string 6 | intervalCount?: number 7 | freeTrialDays?: number 8 | divide_by?: number 9 | } 10 | 11 | export interface Product { 12 | productId: string 13 | title: string 14 | description: string 15 | imageUrl?: string 16 | images?: string[] 17 | keywords?: string 18 | prices?: Price[] 19 | shippable?: boolean 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/types/setting.ts: -------------------------------------------------------------------------------- 1 | export const SettingTypes = { 2 | System: 'system', 3 | Google: 'google', 4 | Auth0: 'auth0', 5 | Internal: 'internal' 6 | } as const 7 | export type SettingType = typeof SettingTypes[keyof typeof SettingTypes] 8 | 9 | export const PaymentSources = { 10 | Stripe: 'stripe', 11 | Paypal: 'paypal', 12 | Fake: 'fake' 13 | } as const 14 | export type PaymentSource = typeof PaymentSources[keyof typeof PaymentSources] 15 | 16 | export const AuthProviders = { 17 | Development: 'fake', 18 | Firebase: 'firebase', 19 | Auth0: 'auth0', 20 | None: 'none' 21 | } as const 22 | export type AuthProviders = typeof AuthProviders[keyof typeof AuthProviders] 23 | 24 | export interface SecretKeys { 25 | token?: string 26 | apiKey?: string 27 | clientSecret?: string 28 | managerSecret?: string 29 | webhookKey?: string 30 | serviceAccountJson?: string 31 | } 32 | 33 | export interface InternalSettings { 34 | startAdminEmail: string 35 | secretManager?: { 36 | enabled: boolean 37 | endpoint: string 38 | region?: string 39 | tokenOrKey?: string 40 | } 41 | secrets?: { 42 | [k in PaymentSource | SettingType]: SecretKeys 43 | } 44 | } 45 | 46 | export interface SystemSettings { 47 | disable: boolean 48 | enableStore: boolean 49 | enableCoins?: boolean 50 | authProvider?: AuthProviders 51 | enableShippingAddress?: boolean 52 | enableCookieConsent?: boolean 53 | enableOneTapLogin?: boolean 54 | enableRegistration?: boolean 55 | paymentMethods?: { 56 | stripe?: PaymentMethodSettings 57 | paypal?: PaymentMethodSettings 58 | } 59 | } 60 | export interface GoogleSettings { 61 | clientId?: string 62 | projectId?: string 63 | analyticsId?: string 64 | enabled?: boolean 65 | apiKey?: string 66 | databaseUrl?: string 67 | messagingSenderId?: string 68 | appId?: string 69 | } 70 | 71 | export interface Auth0Settings { 72 | clientId?: string 73 | tenant?: string 74 | clientAudience?: string 75 | explorerId?: string 76 | sync?: boolean 77 | enabled?: boolean 78 | } 79 | 80 | export interface PaymentMethodSettings { 81 | enabled: boolean 82 | subscriptionsEnabled?: boolean 83 | identityEnabled?: boolean 84 | publishableKey?: string 85 | clientId?: string 86 | } 87 | 88 | export interface Setting { 89 | name: SettingType 90 | data: T 91 | } 92 | export type SettingDataType = SystemSettings & GoogleSettings & Auth0Settings & InternalSettings 93 | export interface SettingData { 94 | [SettingTypes.Internal]: InternalSettings 95 | [SettingTypes.System]: SystemSettings 96 | [SettingTypes.Google]: GoogleSettings 97 | [SettingTypes.Auth0]: Auth0Settings 98 | } 99 | 100 | export type SettingState = { [k in SettingType]: SettingData[k] } 101 | 102 | export type ClientSettings = Partial> 103 | 104 | export interface ClientConfig { 105 | admin: { 106 | models?: string[] 107 | } 108 | ready: boolean 109 | settings: ClientSettings 110 | } 111 | -------------------------------------------------------------------------------- /src/shared/types/subscription.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Order, PaymentSource } from '.' 2 | 3 | export const PlanIntervals = { 4 | Day: 'day', 5 | Week: 'week', 6 | Month: 'month', 7 | Year: 'year' 8 | } as const 9 | 10 | export type PlanInterval = typeof PlanIntervals[keyof typeof PlanIntervals] 11 | 12 | export const PlanStatus = { 13 | Active: 'active', 14 | Expired: 'expired', 15 | Canceled: 'canceled' 16 | } as const 17 | 18 | export type PlanStatus = typeof PlanStatus[keyof typeof PlanStatus] 19 | 20 | export interface SubscriptionPlan extends Entity { 21 | subscriptionPlanId: string 22 | name: string 23 | description?: string 24 | amount?: number 25 | interval?: PlanInterval 26 | intervalCount?: number 27 | trialPeriodDays?: number 28 | mappings: { [key in PaymentSource]?: { productId: string; enabled: boolean } } 29 | enabled?: boolean 30 | } 31 | 32 | export interface Subscription extends Entity { 33 | subscriptionId?: string 34 | userId?: string 35 | orderId?: string 36 | priceId?: string 37 | title?: string 38 | status?: PlanStatus 39 | canceledAt?: Date 40 | cancelationReason?: string 41 | order?: Order 42 | } 43 | -------------------------------------------------------------------------------- /src/shared/types/user copy.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '.' 2 | 3 | export const UserRoles = { 4 | ADMIN: 'admin', 5 | MANAGER: 'manager' 6 | } as const 7 | 8 | export type UserRoleType = typeof UserRoles[keyof typeof UserRoles] 9 | 10 | export interface UserPreferences { 11 | [key: string]: unknown 12 | } 13 | 14 | export interface User extends Entity { 15 | userId: string 16 | email: string 17 | firstName?: string 18 | lastName?: string 19 | picture?: string 20 | banned?: boolean 21 | status?: number 22 | preferences?: UserPreferences 23 | loginCount?: number 24 | lastLogin?: Date 25 | roles?: string[] 26 | } 27 | 28 | export interface Address extends Entity { 29 | addressId: string 30 | userId: string 31 | name: string 32 | address1: string 33 | address2?: string 34 | city: string 35 | state: string 36 | zip: string 37 | country: string 38 | phone: string 39 | favorite?: boolean 40 | } 41 | 42 | export interface PaymentMethod extends Entity { 43 | paymentMethodId: string 44 | userId: string 45 | name: string 46 | type: string 47 | last4: string 48 | expMonth: number 49 | expYear: number 50 | favorite?: boolean 51 | } 52 | 53 | export interface UserActive { 54 | socketId: string 55 | userId: string 56 | ip?: string 57 | userAgent?: string 58 | } 59 | -------------------------------------------------------------------------------- /src/shared/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '.' 2 | 3 | export interface UserPreferences { 4 | [key: string]: unknown 5 | } 6 | 7 | export interface User extends Entity { 8 | userId: string 9 | email: string 10 | firstName?: string 11 | lastName?: string 12 | picture?: string 13 | banned?: boolean 14 | status?: number 15 | preferences?: UserPreferences 16 | loginCount?: number 17 | lastLogin?: Date 18 | roles?: string[] 19 | } 20 | 21 | export interface Address extends Entity { 22 | addressId: string 23 | userId: string 24 | name: string 25 | address1: string 26 | address2?: string 27 | city: string 28 | state: string 29 | zip: string 30 | country: string 31 | phone: string 32 | default?: boolean 33 | } 34 | 35 | export interface PaymentMethod extends Entity { 36 | paymentMethodId: string 37 | userId: string 38 | name: string 39 | type: string 40 | last4: string 41 | expMonth: number 42 | expYear: number 43 | default?: boolean 44 | } 45 | 46 | export interface UserActive { 47 | socketId: string 48 | userId: string 49 | ip?: string 50 | userAgent?: string 51 | } 52 | -------------------------------------------------------------------------------- /src/shared/types/wallet.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '.' 2 | 3 | export interface WalletTransaction extends Entity { 4 | transactionId?: string 5 | userId?: string 6 | amount?: number 7 | orderId?: string 8 | } 9 | 10 | export const WalletStatus = { 11 | Active: 'active', 12 | Flagged: 'flagged', 13 | Frozen: 'frozen', 14 | Closed: 'closed' 15 | } as const 16 | 17 | export type WalletStatus = typeof WalletStatus[keyof typeof WalletStatus] 18 | 19 | export interface Wallet extends Entity { 20 | walletId?: string 21 | balance?: number 22 | currency?: string 23 | status?: WalletStatus 24 | transactions?: WalletTransaction[] 25 | } 26 | -------------------------------------------------------------------------------- /src/shared/util.ts: -------------------------------------------------------------------------------- 1 | import { validate, v4 } from 'uuid' 2 | 3 | export function dashifyUUID(i: string): string { 4 | return ( 5 | i.substring(0, 8) + 6 | '-' + 7 | i.substring(8, 4) + 8 | '-' + 9 | i.substring(12, 4) + 10 | '-' + 11 | i.substring(16, 4) + 12 | '-' + 13 | i.substring(20) 14 | ) 15 | } 16 | 17 | export function tryDashesOrNewUUID(undashed?: string): string { 18 | if (undashed) { 19 | const candidate = dashifyUUID(undashed) 20 | if (validate(candidate)) { 21 | return candidate 22 | } 23 | } 24 | return v4() 25 | } 26 | 27 | export function getPictureMock(payload: Record): string { 28 | let f = '?' 29 | let l = '' 30 | if (!payload?.picture && payload.firstName && payload.lastName) { 31 | f = payload.firstName.charAt(0).toLowerCase() 32 | l = payload.lastName.charAt(0).toLowerCase() 33 | } 34 | return `https://i2.wp.com/cdn.auth0.com/avatars/${f}${l}.png?ssl=1` 35 | } 36 | 37 | export const hello = 'xxxx' 38 | -------------------------------------------------------------------------------- /tests/db/migrations.test.ts: -------------------------------------------------------------------------------- 1 | // import { beforeAllHook } from 'tests/helpers' 2 | import createBackendApp from '../../src/app' 3 | const { checkMigrations } = jest.requireActual('../../src/shared/db/migrator') 4 | const { Connection } = jest.requireActual( 5 | '../../src/shared/db', 6 | ) as typeof import('../../src/shared/db') 7 | // not supposed to be needed, check if this is a bug 8 | 9 | afterAll(() => { 10 | Connection.db.close() 11 | }) 12 | describe('database migrations', () => { 13 | const app = createBackendApp({ checks: true, trace: false }) 14 | test('startup', async () => { 15 | const checks = await app.onStartupCompletePromise 16 | expect(checks[0]).toBeTruthy() 17 | }) 18 | 19 | test('migrate', async () => { 20 | const result = await checkMigrations() 21 | expect(result).toBeTruthy() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/db/models.test.ts: -------------------------------------------------------------------------------- 1 | import { ModelStatic, Model } from 'sequelize' 2 | import { v4 as uuid } from 'uuid' 3 | import { createOrUpdate, deleteIfExists, getIfExists } from '../../src/shared/model-api/controller' 4 | import { beforeAllHook } from '../helpers' 5 | import createBackendApp from '../../src/app' 6 | const { Connection, sortEntities } = jest.requireActual( 7 | '../../src/shared/db', 8 | ) as typeof import('../../src/shared/db') 9 | 10 | jest.mock('../../src/shared/socket') 11 | 12 | const conversions: Record = { 13 | INTEGER: 'number', 14 | BIGINT: 'snumber', 15 | FLOAT: 'number', 16 | DOUBLE: 'number', 17 | DECIMAL: 'number', 18 | NUMERIC: 'number', 19 | REAL: 'number', 20 | DATE: 'date', 21 | DATETIME: 'date', 22 | TIMESTAMP: 'date', 23 | TIME: 'date', 24 | ['TIMESTAMP WITH TIME ZONE']: 'date', 25 | } 26 | 27 | const excluded = ['createdAt', 'updatedAt', 'deletedAt'] 28 | 29 | export function getRandomInt(min: number, max: number) { 30 | min = Math.ceil(min) 31 | max = Math.floor(max) 32 | return Math.floor(Math.random() * (max - min) + min) 33 | } 34 | 35 | export function getMockValue( 36 | model: ModelStatic, 37 | mock: { [key: string]: unknown }, 38 | columnName: string, 39 | columnType: string, 40 | randomize = false, 41 | ) { 42 | const type = conversions[columnType] || columnType 43 | const suffix = randomize ? Math.random() : '' 44 | const increment = randomize ? getRandomInt(1, 1000000) : 0 45 | const [, scale] = type.match(/\d+/g) || [10, 2] 46 | switch (type) { 47 | case 'UUID': 48 | return uuid() 49 | case type.match(/VARCHAR\(\w+\)\[\]/)?.input: 50 | return ['test', 'test2'] 51 | case 'TEXT': 52 | case type.match(/VARCHAR\(\w+\)/)?.input: 53 | return columnName + suffix 54 | case 'number': 55 | return 1 + increment 56 | case 'snumber': 57 | return 1 + increment + '' 58 | case type.match(/DECIMAL\(?.*\)?/)?.input: 59 | return (1 + increment).toFixed(scale as number) + '' 60 | case 'BOOLEAN': 61 | return true 62 | case 'date': 63 | return new Date() 64 | case 'array': 65 | return [1, 2, 3] 66 | case 'JSONB': 67 | case 'JSON': 68 | return { test: 'test' } 69 | case 'VIRTUAL': { 70 | const column = model.getAttributes()[columnName] 71 | if (!column.get) { 72 | return undefined 73 | } 74 | const getter = column.get.bind(mock as unknown as Model) 75 | const bound = getter() 76 | return bound 77 | } 78 | default: 79 | // eslint-disable-next-line no-console 80 | console.log('getMockValue: unhandled type ' + columnType) 81 | return columnName 82 | } 83 | } 84 | 85 | export function getPopulatedModel(model: ModelStatic, cachedKeys: Record) { 86 | const columns = Object.entries(model.getAttributes()).filter( 87 | ([name]) => !excluded.includes(name), 88 | ) as [[string, { type: string; allowNull: boolean }]] 89 | const mock: { [key: string]: unknown } = {} 90 | for (const [name, attribute] of columns) { 91 | mock[name] = getMockValue(model, mock, name, attribute.type.toString()) 92 | if (model.primaryKeyAttribute === name && !cachedKeys[name]) { 93 | cachedKeys[name] = mock[name] 94 | } else if (cachedKeys[name]) { 95 | mock[name] = cachedKeys[name] 96 | } 97 | } 98 | return mock 99 | } 100 | 101 | export function toMatchObjectExceptTimestamps( 102 | expected: { [key: string]: unknown }, 103 | actual: { [key: string]: unknown }, 104 | ) { 105 | for (const key in expected) { 106 | if (!excluded.includes(key)) { 107 | expect(actual[key]).toEqual(expected[key]) 108 | } 109 | } 110 | } 111 | 112 | beforeAll(() => beforeAllHook()) 113 | afterAll(() => { 114 | Connection.db.close() 115 | }) 116 | describe('Entity CRUD', () => { 117 | test('static class', () => { 118 | expect(Connection).toBeTruthy() 119 | }) 120 | const app = createBackendApp({ checks: true, trace: false }) 121 | test('startup', async () => { 122 | const checks = await app.onStartupCompletePromise 123 | expect(checks[0]).toBeTruthy() 124 | }) 125 | 126 | // console.log = jest.fn() 127 | 128 | const sorted = Connection.entities.sort(sortEntities) 129 | const mocks = {} as Record 130 | const keys = {} as Record 131 | for (const entity of sorted) { 132 | const model = entity.model 133 | if (!model) { 134 | throw new Error('No model found for ' + entity.name) 135 | } 136 | if (model.name === 'model') { 137 | throw new Error('Model init() not yet run' + entity.name) 138 | } 139 | const mock = getPopulatedModel(model, keys) 140 | mocks[model.name] = mock 141 | const primaryKeyId = mock[model.primaryKeyAttribute] as string 142 | test(`mock ${model.name}`, async () => { 143 | expect(primaryKeyId).toBeTruthy() 144 | }) 145 | } 146 | 147 | // eslint-disable-next-line no-console 148 | console.log( 149 | 'ready: ', 150 | sorted.map(e => e.name), 151 | keys, 152 | mocks, 153 | ) 154 | 155 | for (const entity of sorted) { 156 | const model = entity.model as ModelStatic 157 | const mock = mocks[model.name] 158 | const primaryKeyId = mock[model.primaryKeyAttribute] as string 159 | if (!model) { 160 | throw new Error('No model found for ' + entity.name) 161 | } 162 | 163 | test(`create ${model.name}`, async () => { 164 | const result = await createOrUpdate(model, mock) 165 | // eslint-disable-next-line no-console 166 | console.log('create result', result) 167 | toMatchObjectExceptTimestamps(mock, result) 168 | }) 169 | 170 | test(`read ${model.name}`, async () => { 171 | const item = await getIfExists(model, primaryKeyId) 172 | const result = item.get() 173 | toMatchObjectExceptTimestamps(mock, result) 174 | }) 175 | 176 | test(`update ${model.name}`, async () => { 177 | const foreignKeys = Object.keys(model.associations).map( 178 | key => model.associations[key].foreignKey, 179 | ) 180 | const attributes = model.getAttributes() 181 | const propName = Object.keys(attributes).find( 182 | key => key !== model.primaryKeyAttribute && !foreignKeys.includes(key), 183 | ) 184 | if (!propName) { 185 | // eslint-disable-next-line no-console 186 | console.error('No updateable properties found - skipping') 187 | return 188 | } 189 | const newValue = getMockValue( 190 | model, 191 | mock, 192 | propName, 193 | attributes[propName].type.toString({}), 194 | true, 195 | ) 196 | const updatedMock = { ...mock, [propName]: newValue } 197 | const result = await createOrUpdate(model, updatedMock) 198 | toMatchObjectExceptTimestamps(updatedMock, result) 199 | }) 200 | } 201 | 202 | //loop models in reverse order 203 | const reversed = [...sorted].reverse() 204 | for (const entity of reversed) { 205 | const model = entity.model as ModelStatic 206 | const mock = mocks[model.name] 207 | if (!mock) { 208 | throw new Error('mock not found') 209 | } 210 | test(`delete ${model.name}`, async () => { 211 | const result = await deleteIfExists(model, mock[model.primaryKeyAttribute] as string) 212 | expect(result).toBeTruthy() 213 | }) 214 | } 215 | }) 216 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | jest.mock('../src/shared/logger') 2 | 3 | export function beforeAllHook() { 4 | // ... 5 | } 6 | -------------------------------------------------------------------------------- /tests/server.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import createBackendApp from '../src/app' 3 | import { getRoutesFromApp } from '../src/shared/server' 4 | import { beforeAllHook } from './helpers' 5 | 6 | beforeAll(() => beforeAllHook()) 7 | describe('server route checks', () => { 8 | const app = createBackendApp({ checks: false, trace: false }) 9 | const routes = getRoutesFromApp(app) 10 | 11 | test('should have at least one route', () => { 12 | expect(routes.length).toBeGreaterThan(0) 13 | }) 14 | test('should return a 200 status code', async () => { 15 | const response = await request(app).get('/') 16 | expect(response.status).toBe(200) 17 | }) 18 | test('should return a 404 status code', async () => { 19 | const response = await request(app).get('/not-found') 20 | expect(response.status).toBe(404) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tools/createEnvironmentHash.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const { createHash } = require('crypto'); 3 | 4 | module.exports = env => { 5 | const hash = createHash('md5'); 6 | hash.update(JSON.stringify(env)); 7 | 8 | return hash.digest('hex'); 9 | }; 10 | -------------------------------------------------------------------------------- /tools/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV || 'production'; 11 | 12 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 13 | const dotenvFiles = [ 14 | `${paths.dotenv}.${NODE_ENV}.local`, 15 | // Don't include `.env.local` for `test` environment 16 | // since normally you expect tests to produce the same 17 | // results for everyone 18 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 19 | `${paths.dotenv}.${NODE_ENV}`, 20 | paths.dotenv, 21 | ].filter(Boolean); 22 | 23 | // Load environment variables from .env* files. Suppress warnings using silent 24 | // if this file is missing. dotenv will never modify any environment variables 25 | // that have already been set. Variable expansion is supported in .env files. 26 | // https://github.com/motdotla/dotenv 27 | // https://github.com/motdotla/dotenv-expand 28 | dotenvFiles.forEach(dotenvFile => { 29 | if (fs.existsSync(dotenvFile)) { 30 | require('dotenv-expand')( 31 | require('dotenv').config({ 32 | path: dotenvFile, 33 | }) 34 | ); 35 | } 36 | }); 37 | 38 | // We support resolving modules according to `NODE_PATH`. 39 | // This lets you use absolute paths in imports inside large monorepos: 40 | // https://github.com/facebook/create-react-app/issues/253. 41 | // It works similar to `NODE_PATH` in Node itself: 42 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 43 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 44 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. 45 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 46 | // We also resolve them to make sure all tools using them work consistently. 47 | const appDirectory = fs.realpathSync(process.cwd()); 48 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 49 | .split(path.delimiter) 50 | .filter(folder => folder && !path.isAbsolute(folder)) 51 | .map(folder => path.resolve(appDirectory, folder)) 52 | .join(path.delimiter); 53 | 54 | function getClientEnvironment(publicUrl) { 55 | const raw = Object.keys(process.env) 56 | .reduce( 57 | (env, key) => { 58 | env[key] = process.env[key]; 59 | return env; 60 | }, 61 | { 62 | // Useful for determining whether we’re running in production mode. 63 | // Most importantly, it switches React into the correct mode. 64 | NODE_ENV: process.env.NODE_ENV || 'development', 65 | // Useful for resolving the correct path to static assets in `public`. 66 | // For example, . 67 | // This should only be used as an escape hatch. Normally you would put 68 | // images into the `src` and `import` them in code to get their paths. 69 | PUBLIC_URL: publicUrl, 70 | // We support configuring the sockjs pathname during development. 71 | // These settings let a developer run multiple simultaneous projects. 72 | // They are used as the connection `hostname`, `pathname` and `port` 73 | // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` 74 | // and `sockPort` options in webpack-dev-server. 75 | WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, 76 | WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, 77 | WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, 78 | // Whether or not react-refresh is enabled. 79 | // It is defined here so it is available in the webpackHotDevClient. 80 | FAST_REFRESH: process.env.FAST_REFRESH !== 'false', 81 | } 82 | ); 83 | // Stringify all values so we can feed into webpack DefinePlugin 84 | const stringified = { 85 | 'process.env': Object.keys(raw).reduce((env, key) => { 86 | env[key] = JSON.stringify(raw[key]); 87 | return env; 88 | }, {}), 89 | }; 90 | 91 | return { raw, stringified }; 92 | } 93 | 94 | module.exports = getClientEnvironment; 95 | -------------------------------------------------------------------------------- /tools/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | // Make sure any symlinks in the project folder are resolved: 7 | // https://github.com/facebook/create-react-app/issues/637 8 | const appDirectory = fs.realpathSync(process.cwd()); 9 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 10 | 11 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 12 | // "public path" at which the app is served. 13 | // webpack needs to know it to put the right