diff --git a/.deploy/api/Dockerfile b/.deploy/api/Dockerfile new file mode 100644 index 000000000..75f85125e --- /dev/null +++ b/.deploy/api/Dockerfile @@ -0,0 +1,75 @@ +ARG HOST +ARG PORT + +FROM node:18-alpine AS build + +LABEL maintainer="meta.digital.cloud@gmail.com" + +ENV CI=true + +RUN apk --update add bash && \ + apk add --no-cache --virtual build-dependencies dos2unix gcc g++ git make python3 vips-dev && \ + mkdir /srv/pangolin && chown -R node:node /srv/pangolin + +WORKDIR /srv/pangolin + +COPY --chown=node:node packages/store/package.json ./packages/store/ +COPY --chown=node:node packages/core/package.json ./packages/core/ +COPY --chown=node:node packages/contracts/package.json ./packages/contracts/ +COPY --chown=node:node packages/common/package.json ./packages/common/ +COPY --chown=node:node packages/config/package.json ./packages/config/ +COPY --chown=node:node packages/auth/package.json ./packages/auth/ +COPY --chown=node:node packages/server/package.json ./packages/server/ +COPY --chown=node:node packages/adapter/package.json ./packages/adapter/ +COPY --chown=node:node packages/analytics/package.json ./packages/analytics/ + +COPY --chown=node:node .deploy/api/package.json ./ + +RUN yarn install && yarn cache clean + +COPY --chown=node:node nx.json ./ +COPY --chown=node:node tsconfig.base.json ./ +COPY --chown=node:node packages ./packages +COPY --chown=node:node apps/api ./apps/api +RUN yarn nx build api + +#//////////////////////////////////////////////////////////////////////////////// +FROM node:18-alpine AS production + +ENV NODE_OPTIONS=${NODE_OPTIONS:-"--max-old-space-size=2048"} \ + NODE_ENV=${NODE_ENV:-production} \ + API_HOST=${API_HOST:-api} \ + API_PORT=${API_PORT:-3000} \ + API_BASE_URL=${API_BASE_URL:-http://localhost:3000} \ + SENTRY_DSN=${SENTRY_DSN} \ + DB_HOST=${DB_HOST:-db} \ + DB_NAME=${DB_NAME:-postgres} \ + DB_PORT=${DB_PORT:-5432} \ + DB_USER=${DB_USER} \ + DB_PASS=${DB_PASS} \ + DB_TYPE=${DB_TYPE:-sqlite} \ + DB_SSL_MODE=${DB_SSL_MODE} \ + HOST=${HOST:-0.0.0.0} \ + PORT=${PORT:-3000} \ + DEMO=${DEMO:-false} + +WORKDIR /srv/pangolin + +RUN npm install pm2 -g + +# COPY dist and dependencies +COPY --chown=node:node --from=build /srv/pangolin/dist/packages ./packages +COPY --chown=node:node --from=build /srv/pangolin/dist/apps/api . +COPY --chown=node:node --from=build /srv/pangolin/tsconfig.base.json ./ +COPY --chown=node:node --from=build /srv/pangolin/yarn.lock . +COPY --chown=node:node .deploy/api/package-prod.json ./package.json + +RUN yarn config set network-timeout 300000 +RUN yarn install --frozen-lockfile && yarn cache clean && \ + touch ormlogs.log && chown node:node ormlogs.log + +USER node:node + +EXPOSE ${PORT} + +CMD [ "pm2-runtime", "main.js" ] diff --git a/.deploy/api/entrypoint.compose.sh b/.deploy/api/entrypoint.compose.sh new file mode 100644 index 000000000..0632dfa33 --- /dev/null +++ b/.deploy/api/entrypoint.compose.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -ex + +# This Entrypoint used inside Docker Compose only + +export WAIT_HOSTS=$DB_HOST:$DB_PORT + +# in Docker Compose we should wait other services start +./wait + +exec "$@" diff --git a/.deploy/api/entrypoint.prod.sh b/.deploy/api/entrypoint.prod.sh new file mode 100644 index 000000000..6bfb10b63 --- /dev/null +++ b/.deploy/api/entrypoint.prod.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -ex + +# This Entrypoint used when we run Docker container outside of Docker Compose (e.g. in k8s) + +exec "$@" diff --git a/.deploy/api/package-prod.json b/.deploy/api/package-prod.json new file mode 100644 index 000000000..4d212797d --- /dev/null +++ b/.deploy/api/package-prod.json @@ -0,0 +1,28 @@ +{ + "name": "metad-server", + "version": "1.6.1", + "license": "MIT", + "scripts": { + "start": "nx serve", + "build": "nx build", + "test": "nx test", + "build:all": "yarn rimraf dist && yarn nx build contracts && yarn nx build common && yarn nx build config && yarn nx build auth && yarn nx build server && yarn nx build adapter && yarn nx build analytics && yarn nx build api" + }, + "private": true, + "dependencies": { + "@nestjs/common": "^8.0.0", + "@nestjs/core": "^8.0.0", + "@nestjs/platform-express": "^8.0.0", + "@swc/helpers": "~0.5.0", + "idb-keyval": "^6.0.2", + "money-clip": "^3.0.5", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.0.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + }, + "workspaces": [ + "packages/*" + ] +} diff --git a/.deploy/api/package.json b/.deploy/api/package.json new file mode 100644 index 000000000..d0484b587 --- /dev/null +++ b/.deploy/api/package.json @@ -0,0 +1,55 @@ +{ + "name": "ocap-server", + "author": "Metad", + "version": "0.4.0-rc.1", + "scripts": { + "start": "nx serve", + "build": "nx build", + "test": "nx test", + "build:all": "yarn rimraf dist && yarn nx build contracts && yarn nx build common && yarn nx build config && yarn nx build auth && yarn nx build server && yarn nx build adapter && yarn nx build analytics && yarn nx build api" + }, + "private": true, + "dependencies": { + "@nestjs/common": "^8.0.0", + "@nestjs/core": "^8.0.0", + "@nestjs/platform-express": "^8.0.0", + "@swc/helpers": "~0.5.0", + "idb-keyval": "^6.0.2", + "money-clip": "^3.0.5", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.0.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@nestjs/schematics": "^10.0.1", + "@nestjs/testing": "^10.0.2", + "@nx/eslint-plugin": "16.6.0", + "@nx/jest": "16.6.0", + "@nx/js": "16.6.0", + "@nx/linter": "16.6.0", + "@nx/nest": "16.6.0", + "@nx/node": "16.6.0", + "@nx/rollup": "16.6.0", + "@nx/web": "16.6.0", + "@nx/webpack": "16.6.0", + "@nx/workspace": "16.6.0", + "@swc/cli": "~0.1.62", + "@swc/core": "~1.3.51", + "@swc/jest": "0.2.20", + "@types/jest": "29.4.4", + "@types/node": "18.7.1", + "@typescript-eslint/eslint-plugin": "5.62.0", + "@typescript-eslint/parser": "5.62.0", + "eslint": "8.15.0", + "eslint-config-prettier": "8.1.0", + "jest": "29.4.3", + "nx": "16.6.0", + "ts-jest": "29.1.1", + "ts-node": "10.9.1", + "typescript": "5.1.3" + }, + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/.deploy/olap/Dockerfile b/.deploy/olap/Dockerfile new file mode 100644 index 000000000..d455ce52f --- /dev/null +++ b/.deploy/olap/Dockerfile @@ -0,0 +1,28 @@ +ARG OLAP_VERSION +ARG REDIS_HOST +ARG REDIS_PORT +ARG REDIS_PASSWORD +ARG REDIS_DATABASE + +# ==================================================== Stage ==========================================================# +FROM maven:3.8-openjdk-11 AS build + +WORKDIR /app + +COPY .deploy/olap/settings.xml /usr/share/maven/ref/ +COPY packages/olap/pom.xml ./pom.xml +RUN mvn -B -s /usr/share/maven/ref/settings.xml dependency:resolve + +COPY packages/olap/src ./src +RUN mvn -B -s /usr/share/maven/ref/settings.xml clean install + +# ==================================================== Stage ==========================================================# +FROM adoptopenjdk:11-jre-hotspot AS webapp + +WORKDIR /app + +COPY --from=build /app/target/olap-0.0.1-SNAPSHOT.jar /app/app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/.deploy/olap/settings.xml b/.deploy/olap/settings.xml new file mode 100644 index 000000000..f394bfc09 --- /dev/null +++ b/.deploy/olap/settings.xml @@ -0,0 +1,31 @@ + + /usr/share/maven/ref/repository + + + + huaweicloud + central + https://mirrors.huaweicloud.com/repository/maven/ + + + + aliyunmaven + central + 阿里云公共仓库 + https://maven.aliyun.com/repository/central + + + repo1 + central + central repo + https://repo1.maven.org/maven2/ + + + aliyunmaven + apache snapshots + 阿里云阿帕奇仓库 + https://maven.aliyun.com/repository/apache-snapshots + + + \ No newline at end of file diff --git a/.deploy/webapp/Dockerfile b/.deploy/webapp/Dockerfile new file mode 100644 index 000000000..c3b9c2078 --- /dev/null +++ b/.deploy/webapp/Dockerfile @@ -0,0 +1,74 @@ +ARG NODE_OPTIONS +ARG NODE_ENV +ARG API_BASE_URL +ARG API_HOST +ARG API_PORT +ARG SENTRY_DSN +ARG DB_HOST +ARG DB_NAME +ARG DB_PORT +ARG DB_USER +ARG DB_PASS +ARG DB_TYPE +ARG DB_SSL_MODE +ARG DEMO +ARG HOST +ARG PORT + +# ==================================================== Stage ==========================================================# +# Copy package.json, Install npm dependencies and Build +FROM node:18-alpine AS build + +LABEL maintainer="meta.digital.cloud@gmail.com" + +ENV CI=true + +RUN apk --update add bash && \ + apk add --no-cache --virtual build-dependencies dos2unix gcc g++ git make python3 vips-dev && \ + mkdir /srv/pangolin && chown -R node:node /srv/pangolin + +COPY wait .deploy/api/entrypoint.prod.sh .deploy/api/entrypoint.compose.sh / +RUN chmod +x /wait /entrypoint.compose.sh /entrypoint.prod.sh && dos2unix /entrypoint.prod.sh && dos2unix /entrypoint.compose.sh + +WORKDIR /srv/pangolin + +COPY --chown=node:node packages/contracts/package.json ./packages/contracts/ + +RUN yarn config set network-timeout 300000 + +COPY --chown=node:node .deploy/webapp/package.json ./package.json +RUN yarn install + +COPY nx.json ./ +COPY tsconfig.base.json ./ +COPY packages ./packages +COPY libs ./libs +COPY apps/cloud ./apps/cloud + +RUN yarn build:cloud:prod + +# ==================================================== Stage ==========================================================# +FROM nginx:alpine AS production + +ENV API_BASE_URL=${API_BASE_URL:-http://localhost:3000} \ + HOST=${HOST:-0.0.0.0} \ + PORT=${PORT:-4200} \ + DEMO=${DEMO:-false} + +WORKDIR /srv/pangolin + +COPY --chown=nginx:nginx --from=build /wait ./ +COPY --chown=nginx:nginx .deploy/webapp/entrypoint.compose.sh ./ +COPY --chown=nginx:nginx .deploy/webapp/entrypoint.prod.sh ./ +COPY --chown=nginx:nginx .deploy/webapp/nginx.compose.conf /etc/nginx/conf.d/compose.conf.template +COPY --chown=nginx:nginx .deploy/webapp/nginx.prod.conf /etc/nginx/conf.d/prod.conf.template +COPY --chown=nginx:nginx --from=build /srv/pangolin/dist/apps/cloud . + +RUN chmod +x wait entrypoint.compose.sh entrypoint.prod.sh && \ + chmod a+rw /etc/nginx/conf.d/compose.conf.template /etc/nginx/conf.d/prod.conf.template + +EXPOSE 4200 + +ENTRYPOINT [ "sh", "./entrypoint.prod.sh" ] + +CMD [ "nginx", "-g", "daemon off;" ] \ No newline at end of file diff --git a/.deploy/webapp/entrypoint.compose.sh b/.deploy/webapp/entrypoint.compose.sh new file mode 100644 index 000000000..589c79f81 --- /dev/null +++ b/.deploy/webapp/entrypoint.compose.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -ex + +# This Entrypoint used inside Docker Compose only + +export WAIT_HOSTS=$API_HOST:$API_PORT + +# In production we should replace some values in generated JS code +sed -i "s#DOCKER_API_BASE_URL#$API_BASE_URL#g" *.js +sed -i "s#DOCKER_CLIENT_BASE_URL#$CLIENT_BASE_URL#g" *.js +sed -i "s#DOCKER_SENTRY_DSN#$SENTRY_DSN#g" *.js +sed -i "s#DOCKER_CLOUDINARY_CLOUD_NAME#$CLOUDINARY_CLOUD_NAME#g" *.js +sed -i "s#DOCKER_CLOUDINARY_API_KEY#$CLOUDINARY_API_KEY#g" *.js +sed -i "s#DOCKER_GOOGLE_MAPS_API_KEY#$GOOGLE_MAPS_API_KEY#g" *.js +sed -i "s#DOCKER_GOOGLE_PLACE_AUTOCOMPLETE#$GOOGLE_PLACE_AUTOCOMPLETE#g" *.js +sed -i "s#DOCKER_DEFAULT_LATITUDE#$DEFAULT_LATITUDE#g" *.js +sed -i "s#DOCKER_DEFAULT_LONGITUDE#$DEFAULT_LONGITUDE#g" *.js +sed -i "s#DOCKER_DEFAULT_CURRENCY#$DEFAULT_CURRENCY#g" *.js +sed -i "s#DOCKER_CHATWOOT_SDK_TOKEN#$CHATWOOT_SDK_TOKEN#g" *.js +sed -i "s#DOCKER_DEMO#$DEMO#g" *.js + +envsubst '${API_HOST} ${API_PORT}' < /etc/nginx/conf.d/compose.conf.template > /etc/nginx/nginx.conf + +# In Docker Compose we should wait other services start +./wait + +exec "$@" diff --git a/.deploy/webapp/entrypoint.prod.sh b/.deploy/webapp/entrypoint.prod.sh new file mode 100644 index 000000000..71d1d1a4e --- /dev/null +++ b/.deploy/webapp/entrypoint.prod.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -ex + +# This Entrypoint used when we run Docker container outside of Docker Compose (e.g. in k8s) + +# In production we should replace some values in generated JS code +sed -i "s#DOCKER_API_BASE_URL#$API_BASE_URL#g" *.js +sed -i "s#DOCKER_CLIENT_BASE_URL#$CLIENT_BASE_URL#g" *.js +sed -i "s#DOCKER_SENTRY_DSN#$SENTRY_DSN#g" *.js +sed -i "s#DOCKER_CLOUDINARY_CLOUD_NAME#$CLOUDINARY_CLOUD_NAME#g" *.js +sed -i "s#DOCKER_CLOUDINARY_API_KEY#$CLOUDINARY_API_KEY#g" *.js +sed -i "s#DOCKER_GOOGLE_MAPS_API_KEY#$GOOGLE_MAPS_API_KEY#g" *.js +sed -i "s#DOCKER_GOOGLE_PLACE_AUTOCOMPLETE#$GOOGLE_PLACE_AUTOCOMPLETE#g" *.js +sed -i "s#DOCKER_DEFAULT_LATITUDE#$DEFAULT_LATITUDE#g" *.js +sed -i "s#DOCKER_DEFAULT_LONGITUDE#$DEFAULT_LONGITUDE#g" *.js +sed -i "s#DOCKER_DEFAULT_CURRENCY#$DEFAULT_CURRENCY#g" *.js +sed -i "s#DOCKER_CHATWOOT_SDK_TOKEN#$CHATWOOT_SDK_TOKEN#g" *.js +sed -i "s#DOCKER_DEMO#$DEMO#g" *.js + +# We may not need to use that env vars now in nginx.config, but we may want later. +# Also we just need to copy nginx.conf to correct place anyway... +envsubst '' < /etc/nginx/conf.d/prod.conf.template > /etc/nginx/nginx.conf + +exec "$@" \ No newline at end of file diff --git a/.deploy/webapp/nginx.compose.conf b/.deploy/webapp/nginx.compose.conf new file mode 100644 index 000000000..341650583 --- /dev/null +++ b/.deploy/webapp/nginx.compose.conf @@ -0,0 +1,48 @@ +user nginx; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + #gzip on; + + upstream api { + server ${API_HOST}:${API_PORT}; + } + + server { + listen 80; + server_name app.mtda.cloud; + + location / { + root /srv/pangolin; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api; + proxy_set_header Host $http_host; + } + } + + server { + listen 80; + server_name mtda.cloud; + + location / { + root /srv/website; + try_files $uri $uri/ /index.html; + } + } +} diff --git a/.deploy/webapp/nginx.prod.conf b/.deploy/webapp/nginx.prod.conf new file mode 100644 index 000000000..0d679e4c3 --- /dev/null +++ b/.deploy/webapp/nginx.prod.conf @@ -0,0 +1,34 @@ +user nginx; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + #gzip on; + + server { + listen 4200; + server_name app.mtda.cloud; + + location ~* \.(gif|jpg|jpeg|png|css|js|ico)$ { + root /srv/pangolin/; + } + + location / { + root /srv/pangolin; + try_files $uri $uri/ /index.html; + } + } + +} diff --git a/.deploy/webapp/package.json b/.deploy/webapp/package.json new file mode 100644 index 000000000..217117f74 --- /dev/null +++ b/.deploy/webapp/package.json @@ -0,0 +1,170 @@ +{ + "name": "ocap", + "author": "Metad", + "version": "0.4.0-rc.1", + "scripts": { + "build:cloud:prod": "set NODE_OPTIONS=--max_old_space_size=8192 & yarn nx build cloud --configuration=production" + }, + "private": true, + "dependencies": { + "@angular/animations": "16.1.7", + "@angular/cdk": "16.1.6", + "@angular/common": "16.1.7", + "@angular/compiler": "16.1.7", + "@angular/core": "16.1.7", + "@angular/elements": "16.1.7", + "@angular/forms": "16.1.7", + "@angular/material": "16.1.6", + "@angular/platform-browser": "16.1.7", + "@angular/platform-browser-dynamic": "16.1.7", + "@angular/router": "16.1.7", + "@angular/service-worker": "16.1.7", + "@antv/g2": "^4.1.34", + "@antv/g2plot": "^2.3.40", + "@casl/ability": "^5.4.3", + "@casl/angular": "^6.0.0", + "@datorama/akita": "^6.2.3", + "@duckdb/duckdb-wasm": "1.25.0", + "@metad/formly-antd": "0.2.0", + "@metad/formly-mat": "0.2.0", + "@microsoft/fetch-event-source": "^2.0.1", + "@ng-matero/extensions": "^13.1.0", + "@ng-web-apis/common": "^2.0.1", + "@ng-web-apis/intersection-observer": "^3.0.0", + "@ng-web-apis/resize-observer": "^2.0.0", + "@ngneat/content-loader": "^7.0.0", + "@ngneat/falso": "^2.27.0", + "@ngneat/until-destroy": "^9.2.2", + "@ngrx/component": "16.0.1", + "@ngrx/component-store": "16.0.1", + "@ngrx/entity": "16.0.1", + "@ngrx/store": "16.0.1", + "@ngx-formly/core": "^6.0.0", + "@ngx-formly/material": "^6.0.0", + "@ngx-translate/core": "^14.0.0", + "@ngx-translate/http-loader": "^7.0.0", + "@popperjs/core": "^2.11.6", + "@sentry/angular": "^7.38.0", + "@sentry/tracing": "^7.38.0", + "@swc/helpers": "~0.5.0", + "@tinymce/tinymce-angular": "^6.0.1", + "angular-gridster2": "^14.0.1", + "apache-arrow": "^9.0.0", + "axios": "^1.0.0", + "clipboard": "^2.0.11", + "css-element-queries": "^1.2.3", + "d3-geo": "3.0.1", + "d3-geo-projection": "^4.0.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "date-fns": "^2.30.0", + "echarts": "^5.4.2", + "echarts-gl": "^2.0.9", + "expr-eval": "^2.0.2", + "hammerjs": "2.0.8", + "idb-keyval": "^6.2.1", + "immer": "^10.0.1", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lato-font": "^3.0.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "marked": "^4.2.12", + "monaco-editor": "^0.28.1", + "money-clip": "^3.0.5", + "ngx-cookie-service": "^14.0.1", + "ngx-logger": "^5.0.12", + "ngx-markdown": "^15.1.1", + "ngx-monaco-editor": "^12.0.0", + "ngx-permissions": "^13.0.1", + "ngx-popperjs": "^15.0.4", + "ngx-quill": "^16.1.2", + "noto-serif-sc": "^8.0.0", + "openai": "^3.2.1", + "prismjs": "^1.29.0", + "quill": "^1.3.7", + "rxjs": "7.8.1", + "short-unique-id": "^4.4.4", + "sql-formatter": "^4.0.2", + "swiper": "^8.0.7", + "timers": "^0.1.1", + "tinymce": "^6.0.0", + "topojson-client": "^3.1.0", + "tslib": "^2.3.0", + "typeorm": "^0.2.37", + "xlsx": "^0.18.5", + "zone.js": "0.13.1" + }, + "devDependencies": { + "@angular-devkit/build-angular": "~16.1.0", + "@angular-devkit/core": "~16.1.0", + "@angular-devkit/schematics": "~16.1.0", + "@angular-eslint/eslint-plugin": "~16.0.0", + "@angular-eslint/eslint-plugin-template": "~16.0.0", + "@angular-eslint/template-parser": "~16.0.0", + "@angular/cli": "~16.1.0", + "@angular/compiler-cli": "~16.1.0", + "@angular/language-service": "~16.1.0", + "@nx/angular": "^16.6.0", + "@nx/cypress": "16.6.0", + "@nx/eslint-plugin": "16.6.0", + "@nx/jest": "16.6.0", + "@nx/js": "16.6.0", + "@nx/linter": "16.6.0", + "@nx/nest": "16.6.0", + "@nx/node": "16.6.0", + "@nx/rollup": "16.6.0", + "@nx/storybook": "16.6.0", + "@nx/web": "16.6.0", + "@nx/webpack": "16.6.0", + "@nx/workspace": "16.6.0", + "@schematics/angular": "16.1.6", + "@storybook/addon-essentials": "7.2.1", + "@storybook/angular": "7.2.1", + "@storybook/core-server": "7.2.1", + "@swc/cli": "~0.1.62", + "@swc/core": "~1.3.51", + "@swc/jest": "0.2.20", + "@types/jest": "29.4.4", + "@types/js-yaml": "^4.0.5", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@types/marked": "^4.0.8", + "@types/node": "18.7.1", + "@types/resize-observer-browser": "^0.1.7", + "@typescript-eslint/eslint-plugin": "5.62.0", + "@typescript-eslint/parser": "5.62.0", + "ajv-formats": "^2.1.1", + "concurrently": "^7.1.0", + "cross-env": "^7.0.3", + "cypress": "^12.16.0", + "eslint": "8.15.0", + "eslint-config-prettier": "8.1.0", + "eslint-plugin-cypress": "^2.10.3", + "exitzero": "1.0.1", + "jest": "29.4.3", + "jest-environment-jsdom": "^29.4.1", + "jest-environment-node": "^29.4.1", + "jest-preset-angular": "13.1.1", + "ng-packagr": "16.1.0", + "nx": "16.6.0", + "postcss": "^8.4.5", + "postcss-import": "14.1.0", + "postcss-preset-env": "7.5.0", + "postcss-url": "10.1.3", + "prettier": "2.7.1", + "tailwindcss": "^3.0.2", + "ts-jest": "29.1.1", + "ts-node": "10.9.1", + "typescript": "5.1.3", + "verdaccio": "^5.0.4" + }, + "workspaces": [ + "apps/*", + "libs/*", + "packages/*" + ], + "nx": { + "includedScripts": [] + } +} diff --git a/.env.compose b/.env.compose new file mode 100644 index 000000000..61f5c8140 --- /dev/null +++ b/.env.compose @@ -0,0 +1,134 @@ +# set true if running inside Docker container +IS_DOCKER=true + +# set true if running as a Demo +DEMO=false + +ALLOW_SUPER_ADMIN_ROLE=true + +# set to Metad OCAP API base URL +API_BASE_URL=http://localhost:3000 + +# set to Metad OCAP UI base URL +CLIENT_BASE_URL=http://localhost:4200 + +# DB_TYPE: sqlite | postgres +DB_TYPE=postgres + +# PostgreSQL Connection Parameters +DB_NAME=ocap +DB_USER=postgres +DB_PASS=ocap_password + +# JWT Session +SESSION_SECRET=metad_ocap +JWT_SECRET=jwtSecret +JWT_REFRESH_SECRET=jwtRefreshSecret +jwtExpiresIn=1h +jwtRefreshExpiresIn=7d + +# Redis +REDIS_PASSWORD=redisPassword + +# Auth SSO +TWITTER_CLIENT_ID=XXXXXXX +TWITTER_CLIENT_SECRET=XXXXXXX +TWITTER_CALLBACK_URL=http://localhost:3000/api/auth/twitter/callback + +GOOGLE_CLIENT_ID=XXXXXXX +GOOGLE_CLIENT_SECRET=XXXXXXX +GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback + +FACEBOOK_CLIENT_ID=XXXXXXX +FACEBOOK_CLIENT_SECRET=XXXXXXX +FACEBOOK_CALLBACK_URL=http://localhost:3000/api/auth/facebook/callback +FACEBOOK_GRAPH_VERSION=v3.0 + +FEISHU_CLIENT_ID= +FEISHU_CLIENT_SECRET= +FEISHU_REDIRECT_URL=http://localhost:3000/api/auth/feishu/callback +FEISHU_APP_TYPE=internal # public 类型的 App 就得提供 appTicket + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:3000/api/auth/github/callback + +LINKEDIN_CLIENT_ID=XXXXXXX +LINKEDIN_CLIENT_SECRET=XXXXXXX +LINKEDIN_CALLBACK_URL=http://localhost:3000/api/auth/linked/callback + +MICROSOFT_CLIENT_ID=XXXXXXX +MICROSOFT_CLIENT_SECRET=XXXXXXX +MICROSOFT_RESOURCE=XXXXXXX +MICROSOFT_TENANT=XXXXXXX +MICROSOFT_CALLBACK_URL=http://localhost:3000/api/auth/microsoft/callback + +FIVERR_CLIENT_ID=XXXXXXX +FIVERR_CLIENT_SECRET=XXXXXXX + +AUTH0_CLIENT_ID=XXXXXXX +AUTH0_CLIENT_SECRET=XXXXXXX +AUTH0_DOMAIN=XXXXXXX + +KEYCLOAK_REALM=XXXXXXX +KEYCLOAK_CLIENT_ID=XXXXXXX +KEYCLOAK_SECRET=XXXXXXX +KEYCLOAK_AUTH_SERVER_URL=XXXXXXX +KEYCLOAK_COOKIE_KEY=XXXXXXX + +INTEGRATED_HUBSTAFF_USER_PASS=hubstaffPassword +UPWORK_CALLBACK_URL=http://localhost:3000/api/integrations/upwork + +# File System +FILE_PROVIDER= + +# AWS Config +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +AWS_S3_BUCKET= + +# SMTP Mail Config +MAIL_FROM_ADDRESS= +MAIL_HOST= +MAIL_PORT= +MAIL_USERNAME= +MAIL_PASSWORD= + +# Sentry Client Key +SENTRY_DSN= + +# Default Currency +DEFAULT_CURRENCY=CNY + +# Default Country +DEFAULT_COUNTRY=CN + +# Google Maps API Key +GOOGLE_MAPS_API_KEY= + +# Chatwoot SDK Token +CHATWOOT_SDK_TOKEN= + +# Restrict Access to Google Place Autocomplete +GOOGLE_PLACE_AUTOCOMPLETE=false + +# Nebular CHAT API key for a map message type (which is required by Google Maps) +CHAT_MESSAGE_GOOGLE_MAP= + +# Default Latitude and Longitude +DEFAULT_LATITUDE= +DEFAULT_LONGITUDE= + +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= + +# Keymetrics settings (optional) +KEYMETRICS_SECRET_KEY= +KEYMETRICS_PUBLIC_KEY= +PM2_APP_NAME=metad +PM2_API_NAME=metad_api +WEB_CONCURRENCY=1 +WEB_MEMORY=4096 + +# Features Toggles diff --git a/.env.tmpl b/.env.local similarity index 89% rename from .env.tmpl rename to .env.local index b26ee7df1..9f6af5f4a 100644 --- a/.env.tmpl +++ b/.env.local @@ -18,19 +18,20 @@ DB_TYPE=postgres # PostgreSQL Connection Parameters # DB_HOST=localhost # DB_PORT=5432 -DB_NAME= -DB_USER= -DB_PASS= - -SESSION_SECRET= -JWT_SECRET= -JWT_REFRESH_SECRET= +DB_NAME=ocap +DB_USER=postgres +DB_PASS=ocap_password + +# JWT Session +SESSION_SECRET=metad_ocap +JWT_SECRET=jwtSecret +JWT_REFRESH_SECRET=jwtRefreshSecret jwtExpiresIn=1h jwtRefreshExpiresIn=7d # REDIS_HOST=localhost # REDIS_PORT=6379 -REDIS_PASSWORD= +REDIS_PASSWORD=redisPassword # OLAP_HOST=localhost # OLAP_PORT=8080 @@ -50,12 +51,12 @@ FACEBOOK_GRAPH_VERSION=v3.0 FEISHU_CLIENT_ID= FEISHU_CLIENT_SECRET= -FEISHU_REDIRECT_URL=http://app.mtda.cloud/api/auth/feishu/callback +FEISHU_REDIRECT_URL=http://localhost:3000/api/auth/feishu/callback FEISHU_APP_TYPE=internal # public 类型的 App 就得提供 appTicket GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -GITHUB_CALLBACK_URL=http://app.mtda.cloud/api/auth/github/callback +GITHUB_CALLBACK_URL=http://localhost:3000/api/auth/github/callback LINKEDIN_CLIENT_ID=XXXXXXX LINKEDIN_CLIENT_SECRET=XXXXXXX diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..870645c54 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +service@mtda.cloud. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..a8b5a34c4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Metad Platform + +We would love for you to contribute to Metad Platform! + +## Submitting Pull Requests + +If you're changing the structure of the repository please create an issue first. + +By default, when you submitting contributions with Pull Requests, we require electronic submission of individual [Contributor Assignment Agreement (CAA)](https://gist.github.com/evereq/95f74ae09510766ffa9379006715ccfd). In some cases (when contributions are very small, at our discretion) instead of CAA we may accept submission of individual [Contributor License Agreement (CLA)](https://gist.github.com/evereq/53ddec283243481344fb61df1706ec40). + +If you submitting contribution on behalf of some legal entity, you need to submit Entity Contributor Assignment Agreement (CAA) or Entity Contributor License Agreement (CLA), which you can request by sending us an email to service@mtda.cloud. + +We are using open-source [CLA assistant](https://github.com/cla-assistant/cla-assistant) project to collect your signatures on CAA. +The templates for our CAA/CLA documents generated by http://www.harmonyagreements.org. + +## Submitting bug reports + +Make sure you are on latest changes. +If you can, please provide more information about your environment such as browser, operating system, node version, and yarn version. + +## Feature requests+ + +You are more than welcome to submit future requests here https://github.com/meta-d/ocap/issues + +## Legal + +This is an open source project. +Contributions you make to this public Metad Platform repository are completely voluntary. +When you submit an issue, bug report, question, enhancement, pull request, etc., you are offering your contribution without expectation of payment, you expressly waive any future pay claims against the Metad Co. LTD related to your contribution, and you acknowledge that this does not create an obligation on the part of the Metad Co. LTD of any kind. Furthermore, your contributing to this project does not create an employer-employee relationship between the Metad Co. LTD and the contributor. + +See also "Submitting Pull Requests" section above for more information on CAA/CLA, required for any contributions. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..59c744861 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +# PR + +- [ ] Have you followed the [contributing guidelines](https://github.com/meta-d/ocap/blob/master/.github/CONTRIBUTING.md)? +- [ ] Have you explained what your changes do, and why they add value? + +**Please note: we will close your PR without comment if you do not check the boxes above and provide ALL requested information.** + +--- diff --git a/.gitignore b/.gitignore index ac63786c3..aaaeacd52 100644 --- a/.gitignore +++ b/.gitignore @@ -47,11 +47,11 @@ Thumbs.db /packages/**/.cache /packages/**/dist /packages/**/node_modules +/.deploy/clickhouse/log # sqlite3 database *.sqlite3 *.sqlite3-journal -.scripts/*.d/ /ormlogs.log diff --git a/.scripts/initdb.d/1.initdb.sql b/.scripts/initdb.d/1.initdb.sql new file mode 100644 index 000000000..1650198ab --- /dev/null +++ b/.scripts/initdb.d/1.initdb.sql @@ -0,0 +1,2 @@ +CREATE SCHEMA IF NOT EXISTS demo; +CREATE SCHEMA IF NOT EXISTS foodmart; \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 27f709fc9..000000000 --- a/LICENSE +++ /dev/null @@ -1,137 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license - for software and other kinds of works, specifically designed to - ensure cooperation with the community in the case of network server - software. - - The licenses for most software and other practical works are designed - to take away your freedom to share and change the works. By contrast, - our General Public Licenses are intended to guarantee your freedom to - share and change all versions of a program--to make sure it remains free - software for all its users. - - When we speak of free software, we are referring to freedom, not - price. Our General Public Licenses are designed to make sure that you - have the freedom to distribute copies of free software (and charge for - them if you wish), that you receive source code or can get it if you - want it, that you can change the software or use pieces of it in new - free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you - these rights or asking you to surrender the rights. Therefore, you - have certain responsibilities if you distribute copies of the software, - or if you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether - gratis or for a fee, you must pass on to the recipients the same - freedoms that you received. You must make sure that they, too, receive - or can get the source code. And you must show them these terms so they - know their rights. - - Developers that use the GNU AGPL protect your rights with two steps: - (1) assert copyright on the software, and (2) offer you this License - which gives you legal permission to copy, distribute and/or modify the - software. - - For the developers' and authors' protection, the GPL clearly explains - that there is no warranty for this free software. For both users' and - authors' sake, the GPL requires that modified versions be marked as - changed, so that their problems will not be attributed erroneously to - authors of previous versions. - - Some devices are designed to deny users access to install or run - modified versions of the software inside them, although the manufacturer - can do so. This is fundamentally incompatible with the aim of - protecting users' freedom to change the software. The systematic - pattern of such abuse occurs in the area of products for individuals to - use, which is precisely where it is most unacceptable. Therefore, we - have designed this version of the GPL to prohibit the practice for those - products. If such problems arise substantially in other domains, we - stand ready to extend this provision to those domains in future versions - of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. - States should not allow patents to restrict development and use of - software on general-purpose computers, but in those that do, we wish to - avoid the special danger that patents applied to a free program could - make it effectively proprietary. To prevent this, the GPL assures that - patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and - modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public - License. - - "Copyright" also means copyright-like laws that apply to other kinds of - works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this - License. Each licensee is addressed as "you". "Licensees" and - "recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work - in a fashion requiring copyright permission, other than the making of an - exact copy. The resulting work is called a "modified version" of the - earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based - on the Program. - - To "propagate" a work means to do anything with it that, without - permission, would make you directly or secondarily liable for - infringement under applicable copyright law, except executing it on a - computer or modifying a private copy. Propagation includes copying, - distribution (with or without modification), making available to the - public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other - parties to make or receive copies. Mere interaction with a user through - a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" - to the extent that it includes a convenient and prominently visible - feature that (1) displays an appropriate copyright notice, and (2) - tells the user that there is no warranty for the work (except to the - extent that warranties are provided), that licensees may convey the - work under this License, and how to view a copy of this License. If - the interface presents a list of user commands or options, such as a - menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work - for making modifications to it. "Object code" means any non-source - form of a work. - - A "Standard Interface" means an interface that either is an official - standard defined by a recognized standards body, or, in the case of - interfaces specified for a particular programming language, one that - is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other - than the work as a whole, that (a) is included in the normal form of - packaging a Major Component, but which is not part of that Major - Component, and (b) serves only to enable use of the work with that - Major Component, or to implement a Standard Interface for which an - implementation is available to the public in source code form. A - "Major Component", in this context, means a major essential component - (kernel, window system, and so on) of the specific operating system - (if any) on which the executable work runs, or a compiler used to - produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all - the source code needed to generate, install, and (for an executable - work) run the object code and to modify the work, including scripts to diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..19dde32a5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,60 @@ +# License + +Copyright © 2022-present, Metad Co. LTD. All rights reserved. + +This document represent official information about our licensing, make sure you read and understand it before start using software and source code. + +This software is available under an Open Source Licenses ("Community Edition"). It is suitable if your business can comply with the requirements of corresponding open-source licenses, see more information below (e.g. requirements to release your modifications under the same open-source licenses for the benefits of our community). + +Alternatively, commercial versions of the software must be used in accordance with the terms and conditions of separate written license agreement between you and Metad Co. LTD. With commercial license, your source code (including your changes) is kept proprietary. You can purchase a commercial licenses at . + +In addition, Metad Co. LTD holds copyright and/or sufficient licenses to all components of the Metad Analytics Platform, and therefore can grant, at its sole discretion, the ability for companies, individuals, or organizations to create proprietary modules which may be dynamically linked at runtime with the portions of Metad Analytics Platform which fall under our copyright/license umbrella. + +**The default Metad Analytics Platform license, without a valid Metad Analytics Platform Small Business or Metad Analytics Platform Enterprise License agreement, is the Metad Analytics Platform Community Edition License.** + +### _Metad Analytics Platform Community Edition_ License + +Metad Analytics Platform Community Edition available at https://github.com/meta-d/ocap released under [GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.txt). + +If you decide to choose the Metad Analytics Platform Community Edition License, you must comply with the following terms: + +This program is free software: you can redistribute it and/or modify it under the terms of the corresponding licenses described in the LICENSE.md files located in software sub-folders and under the terms of licenses described in individual files. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +You should have received a copy of the relevant GNU Licenses along with this program. If not, see . + +We suggest to check a great overview about different open-source licenses at . +For example, for AGPL v3 (strongest copyleft license we use) conditions can be summarized as following: + +- making available complete source code of licensed works and modifications, which include larger works using a licensed work, under the same license. +- Copyright and license notices must be preserved +- Contributors provide an express grant of patent rights. +- When a modified version is used to provide a service over a network, the complete source code of the modified version must be made available. + +Feel free to [Contact Us](https://github.com/meta-d/ocap#contact-us) for an additional information about used open-source licenses! + +### _Metad Analytics Platform Small Business_ License + +Metad Analytics Platform Small Business License can be purchased by small businesses with annual revenues do not exceed \$1 million and used for single owned Company. +For more information, please see https://mtda.cloud/pricing or contact us at . + +### _Metad Analytics Platform Enterprise_ License + +Metad Analytics Platform Enterprise License can be purchased by businesses with more than \$1 million in annual revenue and used for unlimited amount of owned companies. +For more information, please see https://mtda.cloud/pricing or contact us at . + +## Credits + +Please see [CREDITS.md](CREDITS.md) files for a list of libraries and software included in this program and information about licenses. + +## Trademarks + +**Metad**® is a registered trademark of [Metad Co. LTD](https://mtda.cloud). +**Ocap**™ is a trademark of [Metad Co. LTD](https://mtda.cloud). + +The trademarks and logos may only be used with the written permission of Metad Co. LTD. and may not be used to promote or otherwise market competitive products or services. If you wish to use these trademarks and logos you should contact our licensing department at to determine the necessary steps you must take. + +All other brand and product names are trademarks, registered trademarks or service marks of their respective holders. + +If you have any questions regarding our licensing policy, please contact us: (via email) diff --git a/README.md b/README.md index ee0083691..dea5e5903 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ +English | [中文](./README_zh.md) + # Metad Analytics Platform -[uri_metad]: https://mtda.cloud +[uri_metad]: https://mtda.cloud/en/ [uri_license]: https://www.gnu.org/licenses/agpl-3.0.html [uri_license_image]: https://img.shields.io/badge/License-AGPL%20v3-blue.svg @@ -10,50 +12,199 @@ ## 💡 What's New +We released new version which includes [AI Copilot](https://mtda.cloud/en/blog/copilot-1-assist-data-query) in query lab, it can assit you to write and optimize SQL or MDX queries. + ## 🌟 What is it +[Metad Platform][uri_metad] - **Open-Source Analytics Platform** for Enterprise Data Analysis and Reporting. + +* **Semantic Model**: Perform multi-dimensional data modeling and analysis, allowing users to explore data from various dimensions and hierarchies. +* **Story Dashboard**: Create compelling visual narratives with Story Dashboards, combining interactive visualizations, narrative elements, and data-driven storytelling. +* **Indicator Management**: Easily define, manage, and monitor key performance indicators (KPIs) to ensure data quality, consistency, and effective performance analysis. +* **AI Copilot**: Benefit from AI-driven insights and recommendations to enhance decision-making processes and identify actionable opportunities. + +![Story Workspace](https://github.com/meta-d/meta-d/blob/main/img/story-workspace.png) + +![Indicator Application](https://github.com/meta-d/meta-d/blob/main/img/indicator-application.png) + ## ✨ Features +Main features: + +* **Data Sources**: connects with lots of different databases and data warehouses. +* **Semantic Model**: Supports the unified semantic modeling of two olap engines: MDX and SQL, and supports multi-dimensional modeling and analysis. + * **Query Lab**: An environment for executing and analyzing SQL or MDX queries, with AI Copilot to assist in writing and optimizing SQL or MDX queries. + * **Virtual Cube**: combine dimensions and measures from multiple cubes. + * **Access Control**: The access control of the cube defined based on single role or combined role to the row level. + * **External Cube**: support cube from third-party multiple-dimensional data source, such as SSAS, SAP BW/BPC etc. + * **Calculated Members**: support calculated dimension members and calculated measures using MDX or SQL expression. +* **Project**: A project is a collection of story dashboards, indicators and other resources that are used to create and deliver analytics content collaborating with colleagues. +* **Indicator Management**: Define, manage, and monitor key performance indicators (KPIs) to ensure data quality, consistency, and effective performance analysis. + * Indicator registration + * Indicator certification + * Indicator business area + * Derivative indicator + * Indicator measure +* **Indicator Market**: Publish and share indicators with other users in one place. +* **Indicator Application**: View and analyze indicators in a dedicated single page application. +* **Story Dashboard**: Create compelling visual narratives with Story Dashboards, combining interactive visualizations, narrative elements, and data-driven storytelling. + * **Bigview Dashboard**: A story dashboard suitable for large screen display, supporting data automatic refresh and scrolling display. + * **Mobile Design**: support mobile terminal adaptive design, support mobile terminal browser access. + * **Story Template**: Create and share a unified style and layout template of story. + * **Execution Explain**: Explain the execution process of SQL or MDX queries inculde query statement, slicers, query result and chart options. + * **AI Copilot**: assist users quickly design and implement story dashboards. + +Basic feartures of the platform: +* Multi-tenant +* Multiple Organizations Management +* Home Dashboard +* Roles / Permissions +* Tags / Labels +* Custom SMTP +* Email Templates +* Copilot +* Country +* Currency +* Logger +* Storage File +* User +* Invite +* Business Area +* Certification +* Dark / Light / Thin and other themes + ## 🌼 Screenshots
Show / Hide Screenshots ### Sales overview [open in new tab](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b) -![](https://github.com/meta-d/meta-d/blob/main/img/adv-sales-overview.png) +![Sales overview Screenshot](https://github.com/meta-d/meta-d/blob/main/img/adv-sales-overview.png) ### Pareto analysis [open in new tab](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b?pageKey=bsZ0sjxnxI) -![](https://github.com/meta-d/meta-d/blob/main/img/product-pareto-analysis.png) +![Pareto analysis Screenshot](https://github.com/meta-d/meta-d/blob/main/img/product-pareto-analysis.png) ### Product profit analysis [open in new tab](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b?pageKey=6S4oEUnVO3) -![](https://github.com/meta-d/meta-d/blob/main/img/profit-margin-analysis.jpg) +![Product profit analysis Screenshot](https://github.com/meta-d/meta-d/blob/main/img/profit-margin-analysis.jpg) ### Reseller analysis [open in new tab](https://app.mtda.cloud/public/story/a58112aa-fc9c-4b5b-a04e-4ea9b57ebba9?pageKey=nrEZxh1aqp) -![](https://github.com/meta-d/meta-d/blob/main/img/reseller-profit-analysis.png) +![Reseller analysis Screenshot](https://github.com/meta-d/meta-d/blob/main/img/reseller-profit-analysis.png) ### Bigview dashboard [open in new tab](https://app.mtda.cloud/public/story/9c462bea-89f6-44b8-a35e-34b21cd15a36) -![](https://github.com/meta-d/meta-d/blob/main/img/bigview-supermart-sales.png) +![Bigview dashboard Screenshot](https://github.com/meta-d/meta-d/blob/main/img/bigview-supermart-sales.png) ### Indicator application [open in new tab](https://www.mtda.cloud/en/blog/2023/07/24/sample-adv-7-indicator-app) -![](https://github.com/meta-d/meta-d/blob/main/img/indicator-application.png) +![Indicator application Screenshot](https://github.com/meta-d/meta-d/blob/main/img/indicator-application.png) ### Indicator mobile app [open in new tab](https://www.mtda.cloud/en/blog/2023/07/24/sample-adv-7-indicator-app) -![](https://github.com/meta-d/meta-d/blob/main/img/indicator-app-mobile.jpg) +![Indicator mobile app Screenshot](https://github.com/meta-d/meta-d/blob/main/img/indicator-app-mobile.jpg)
-## Build +## 🔗 Links + +* check more information about the platform at the official website. +* check the official documentation and tutorials for more details. +* Check out the official blog for the latest updates. +* Login to Metad analytics platform for free use. + +## 💻 Demo, Downloads, Testing and Production + +### Demo + +Metad Analytics Platform Demo at . + +Notes: +- You can generate samples data in the home dashbaord page. + +### Downloads + +You can download [Metad Desktop Agent](https://github.com/meta-d/meta-d/releases) use to connect to your local data sources. + +### Production (SaaS) + +Metad Analytics Platform SaaS is available at . + +Note: it's currently in Alpha version / in testing mode, please use it with caution! + +## 🧱 Technology Stack and Requirements + +- [TypeScript](https://www.typescriptlang.org) language +- [NodeJs](https://nodejs.org) / [NestJs](https://github.com/nestjs/nest) +- [Nx](https://nx.dev) +- [Angular](https://angular.io) +- [RxJS](http://reactivex.io/rxjs) +- [TypeORM](https://github.com/typeorm/typeorm) +- [ECharts](https://echarts.apache.org/) +- [Java](https://www.java.com/) +- [Mondrian](https://github.com/pentaho/mondrian) + +For Production, we recommend: + +- [PostgreSQL](https://www.postgresql.org) +- [PM2](https://github.com/Unitech/pm2) + +Note: thanks to TypeORM, OCAP will support lots of DBs: SQLite (default, for demos), PostgreSQL (development/production), MySql, MariaDb, CockroachDb, MS SQL, Oracle, MongoDb, and others, with minimal changes. + +#### See also README.md and CREDITS.md files in relevant folders for lists of libraries and software included in the Platform, information about licenses, and other details + +## 📄 Documentation + +Please refer to our official [Platform Documentation](https://mtda.cloud/en/docs/) and to our [Wiki](https://github.com/meta-d/ocap/wiki) (WIP). + +## 🚀 Quick Start + +### With Docker Compose + +- Clone repo. +- Make sure you have Docker Compose [installed locally](https://docs.docker.com/compose/install). +- Copy `.env.compose` file into `.env` file in the root of mono-repo (the file contains default env variables definitions). +- Run `docker-compose -f docker-compose.demo.yml up`, if you want to run the platform using our prebuild Docker images. _(Note: it uses latest images pre-build automatically from head of `main` branch using GitHub CI/CD.)_ +- Run `docker-compose up`, if you want to build everything (code and Docker images) locally. _(Note: this is extremely long process, option above is much faster.)_ +- Open in your browser. +- The first time you will enter the onborading page. Follow the prompts to complete the initial settings ( organization, samples and connect your data source), and then you can start using it. +- Enjoy! + +### Manually + +#### Required + +- Install [NodeJs](https://nodejs.org/en/download) LTS version or later, e.g. 18.x. +- Install [Yarn](https://github.com/yarnpkg/yarn) (if you don't have it) with `npm i -g yarn`. +- Install NPM packages and bootstrap solution using the command `yarn bootstrap`. +- Copy [`.env.local`](./.env.local) file into `.env` and adjust settings in the file which is used in local runs. +- Run command `docker-compose -f docker-compose.dev.yml up -d` to start PostgreSQL database and redis services. +- Run both API, UI and OLAP engine with a single command: `yarn start`, or run them separately with `yarn start:api`, `yarn start:cloud` and `yarn start:olap`. +- Open Metad UI on in your browser (API runs on ). +- Onboarding... +- Enjoy! + +### Production + +- For simple deployment scenarios (e.g. for yourself or your own small organization), check our [Docker Compose file](./docker-compose.demo.yml), which we are using to deploy Metad Analytics Platform to docker cluster. +- For production deployment scenarios (e.g. for enterprise organization), check our [Kubernetes configurations](https://github.com/meta-d/ocap/tree/develop/.deploy/k8s), which we are using to deploy Metad Analytics Platform into Kubernetes platform, for example [Aliyun k8s cluster](https://cn.aliyun.com/product/kubernetes). + +## 💌 Contact Us + +- For business inquiries: +- [Metad Platform @ Twitter](https://twitter.com/CloudMtda) + +## 🛡️ License + +We support the open-source community. + +This software is available under the following licenses: + +- [Metad Analytics Platform Community Edition](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-community-edition-license) +- [Metad Analytics Platform Small Business](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-small-business-license) +- [Metad Analytics Platform Enterprise](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-enterprise-license) -```bash -yarn build -npx tailwindcss -c packages/angular//tailwind.config.js -i ./packages/angular/_index.scss -o ./dist/packages/angular/index.css -m1 -``` -### Thank You +#### Please see [LICENSE](LICENSE.md) for more information on licenses. -OCAP would not be possible without the support and assistance of other open-source tools and companies. Visit our [thank you page](THANK-YOU.md) to learn more about how we build OCAP. +## 🍺 Contribute - - - +- Please give us :star: on Github, it **helps**! +- You are more than welcome to submit feature requests in the [ocap repo](https://github.com/meta-d/ocap/issues) +- Pull requests are always welcome! Please base pull requests against the _develop_ branch and follow the [contributing guide](.github/CONTRIBUTING.md). diff --git a/README_zh.md b/README_zh.md index cc5b48b0c..652c810d7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,10 +1,211 @@ -# Metad Ocap +[English](./README.md) | 中文 -提供统一规范的嵌入式分析工具. +# Metad 分析平台 -## Build +[uri_metad]: https://mtda.cloud/ +[uri_license]: https://www.gnu.org/licenses/agpl-3.0.html +[uri_license_image]: https://img.shields.io/badge/License-AGPL%20v3-blue.svg -```bash -yarn build -npx tailwindcss -c packages/angular//tailwind.config.js -i ./packages/angular/_index.scss -o ./dist/packages/angular/index.css -m1 -``` +![visitors](https://visitor-badge.laobi.icu/badge?page_id=meta-d.ocap) +[![License: AGPL v3][uri_license_image]][uri_license] +[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/meta-d/ocap) + +## 💡 新功能 + +我们发布了新版本,其中包含 [AI 副驾驶](https://mtda.cloud/blog/copilot-1-assist-data-query),它可以帮助您编写和优化 SQL 或 MDX 查询语句。 + +## 🌟 简介 + +[Metad 分析平台][uri_metad] - 企业级数据和报表 **开源分析平台**。 + +* **语义模型**: 执行多维数据建模和分析,允许用户从不同的维度和层次探索数据。 +* **故事仪表板**: 使用故事仪表板创建引人注目的视觉叙述,将交互式可视化、叙述元素和数据驱动的叙述组合在一起。 +* **指标管理**: 轻松定义、管理和监控关键绩效指标(KPI),以确保数据质量、一致性和有效的绩效分析。 +* **AI 副驾驶**: 从人工智能驱动的见解和建议中受益,以增强决策流程并识别可行的机会。 + +![故事工作空间](https://github.com/meta-d/meta-d/blob/main/img/story-workspace.png) + +![指标应用](https://github.com/meta-d/meta-d/blob/main/img/indicator-application.png) + +## ✨ 功能 + +主要功能: + +* **数据源**: 连接到各种不同的数据库和数据仓库。 +* **语义模型**: 支持两种 OLAP 引擎 MDX 和 SQL 的统一语义建模,支持多维建模和分析。 + * **查询实验室**: 执行和分析 SQL 或 MDX 查询的环境,并具有 AI 副驾驶以帮助编写和优化 SQL 或 MDX 查询。 + * **虚拟立方体**: 从多个立方体中组合维度和度量形成一个虚拟的立方体。 + * **访问控制**: 基于单一角色或者组合角色的行级别访问控制定义的立方体。 + * **外部立方体**: 支持第三方多维数据源,如 SSAS、SAP BW/BPC 等。 + * **计算成员**: 支持使用 MDX 或 SQL 表达式创建计算维度成员和度量。 +* **项目**: 项目是一组故事仪表板、指标和其他资源,用于创建和交付分析内容,与同事合作。 +* **指标管理**: 定义,管理和监控关键绩效指标(KPI),以确保数据质量,一致性和有效的绩效分析。 + * 指标注册 + * 指标认证 + * 指标业务域 + * 衍生指标 + * 指标度量 +* **指标市场**: 在一个地方发布和分享指标给其他用户。 +* **指标应用**: 在专用的单页应用程序中查看和分析指标。 +* **故事仪表板**: 使用故事表板创建引人入胜的视觉叙事,结合交互式可视化、叙事元素和数据驱动的叙事。 + * **大屏**: 适用于大屏展示的故事仪表盘,支持数据自动刷新和滚动展示。 + * **移动端**: 支持移动端自适应设计、支持移动端浏览器访问。 + * **故事模版**: 创建并分享一个统一的故事样式和布局模板。 + * **执行解释**: 解释数据查询和展示的执行过程,包括查询语句,切片器,查询结果和图表选项。 + * **AI 副驾驶**: 帮助用户快速设计和实现故事仪表板。 + +平台的基本功能: +* 多租户 +* 多组织管理 +* 主页仪表盘 +* 角色 / 权限 +* 标签 / 标签 +* 自定义 SMTP +* 电子邮件模板 +* AI 副驾驶 +* 国家 +* 货币 +* 日志记录器 +* 存储文件 +* 用户 +* 邀请 +* 业务域 +* 认证 +* 深色 / 浅色 / 轻色和其他主题 + +## 🌼 屏幕截图 + +
+显示/隐藏截图 + +### 销售概览 [在新页签打开](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b) +![销售概览截图](https://github.com/meta-d/meta-d/blob/main/img/adv-sales-overview.png) + +### 帕累托分析 [在新页签打开](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b?pageKey=bsZ0sjxnxI) +![帕累托分析截图](https://github.com/meta-d/meta-d/blob/main/img/product-pareto-analysis.png) + +### 产品利润分析 [在新页签打开](https://app.mtda.cloud/public/story/892690e5-66ab-4649-9bf5-c1a9c432c01b?pageKey=6S4oEUnVO3) +![产品利润分析截图](https://github.com/meta-d/meta-d/blob/main/img/profit-margin-analysis.jpg) + +### 经销商分析 [在新页签打开](https://app.mtda.cloud/public/story/a58112aa-fc9c-4b5b-a04e-4ea9b57ebba9?pageKey=nrEZxh1aqp) +![经销商分析截图](https://github.com/meta-d/meta-d/blob/main/img/reseller-profit-analysis.png) + +### 大屏仪表板 [在新页签打开](https://app.mtda.cloud/public/story/9c462bea-89f6-44b8-a35e-34b21cd15a36) +![大屏仪表板截图](https://github.com/meta-d/meta-d/blob/main/img/bigview-supermart-sales.png) + +### 指标应用 [在新页签打开](https://www.mtda.cloud/en/blog/2023/07/24/sample-adv-7-indicator-app) +![指标应用截图](https://github.com/meta-d/meta-d/blob/main/img/indicator-application.png) + +### 指标应用移动端 [在新页签打开](https://www.mtda.cloud/en/blog/2023/07/24/sample-adv-7-indicator-app) +![指标应用移动端截图](https://github.com/meta-d/meta-d/blob/main/img/indicator-app-mobile.jpg) + +
+ +## 🔗 链接 + +* 查看更多关于该平台的信息,请访问官方网站。 +* 查看官方文档和教程了解详细使用。 +* 查看官方博客了解最新动态。 +* 登录到 Metad 分析平台免费使用。 + +## 💻 演示,下载,测试和生产 + +### 演示 + +Metad 分析平台演示地址 。 + +注意: +- 您可以在首页生成样本数据。 + +### 下载 + +您可以下载 [Metad 桌面代理](https://github.com/meta-d/meta-d/releases) 用于连接 Metad 分析云到您的本地数据源。 + +### 生产 (SaaS) + +Metad 分析云平台链接为 。 + +注意: 它目前处于 Alpha 版本/测试模式,请谨慎使用! + +## 🧱 技术栈 + +- [TypeScript](https://www.typescriptlang.org) language +- [NodeJs](https://nodejs.org) / [NestJs](https://github.com/nestjs/nest) +- [Nx](https://nx.dev) +- [Angular](https://angular.io) +- [RxJS](http://reactivex.io/rxjs) +- [TypeORM](https://github.com/typeorm/typeorm) +- [ECharts](https://echarts.apache.org/) +- [Java](https://www.java.com/) +- [Mondrian](https://github.com/pentaho/mondrian) + +对于生产环境,我们推荐: + +- [PostgreSQL](https://www.postgresql.org) +- [PM2](https://github.com/Unitech/pm2) + +注意:多亏了 Metad 将支持大量的数据库:PostgreSQL(开发/生产),MySql,MariaDb,CockroachDb,MS SQL,Oracle,MongoDb,以及其他,只需最小的更改。 + +#### 请参阅相应文件夹中的 README.md 和 CREDITS.md 文件以获取包含在平台中的库和软件列表,有关许可证的信息以及其他详细信息 + +## 📄 文档 + +请参阅我们的 [官方文档](https://mtda.cloud/docs/) 和项目 [Wiki](https://github.com/meta-d/ocap/wiki) (WIP). + +## 🚀 快速开始 + +### 使用 Docker Compose + +- 克隆代码库. +- 保证您已经安装了 Docker Compose [本地安装](https://docs.docker.com/compose/install). +- 复制 `.env.compose` 文件到 `.env` 放在项目根目录 (此文件包含了默认的环境变量定义)。 +- 运行命令 `docker-compose -f docker-compose.demo.yml up`, 将使用我们预先构建的 Docker 镜像运行此平台。 _(注意: 它使用最新的镜像,自动从 GitHub 代码仓库的 `main` 分支的最新提交构建。)_ +- 运行 `docker-compose up`, 将在本地构建所有的代码和镜像。 _(注意: 这是一个非常漫长的过程还可能遇到网络问题,请优先使用上一中运行方式.)_ +- 在浏览器中打开链接 。 +- 首次打开将进入 *首次配置* 页面。按照提示完成初始设置(组织,样本和连接数据源),然后就可以开始使用它了。 +- 尽情享受! + +### 手动 + +#### 要求 + +- 安装 [NodeJs](https://nodejs.org/en/download) LTS 版本或更新的, 例如 18.x 。 +- 用 `npm i -g yarn` 安装 [Yarn](https://github.com/yarnpkg/yarn) (如果还没有)。 +- 用命令 `yarn bootstrap` 安装 NPM 包和启动方案。 + +#### 运行 + +- 复制 [`.env.local`](./.env.local) 文件到 `.env` 然后调整文件中的配置用于本地运行程序。 +- 运行命令 `docker-compose -f docker-compose.dev.yml up -d` 启动程序所需的 PostgreSQL 数据库和 redis 服务。 +- 使用一个命令 `yarn start` 同时启动 API, UI 和 OLAP 引擎三个服务,或者分别启动 `yarn start:api`, `yarn start:cloud` 和 `yarn start:olap`. +- 在浏览器中打开 Metad 界面链接 (API 运行在 )。 +- 首次运行配置系统... +- 尽情享受! + +### 生产 +- 对于简单的部署场景(例如,用于个人或您自己的小型组织),请查看我们的 [Docker 配置](./docker-compose.demo.yml),我们使用这些配置来部署 Metad 分析平台到 Docker 集群。 +- 对于企业大规模部署, 请参考我们的 [Kubernetes 配置](https://github.com/meta-d/ocap/tree/develop/.deploy/k8s), 用来部署 Metad 分析平台到 Kubernetes 集群环境中,如 [Aliyun k8s 集群](https://cn.aliyun.com/product/kubernetes). + +## 💌 联系我们 + +- 商务合作: +- [Metad 平台 @ Twitter](https://twitter.com/CloudMtda) + +## 🛡️ 许可证 + +我们支持开源社区。 + +此软件在以下许可下可用: + +- [Metad 分析平台社区版](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-community-edition-license) +- [Metad 分析平台小企业版](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-small-business-license) +- [Metad 分析平台企业版](https://github.com/meta-d/ocap/blob/master/LICENSE.md#metad-analytics-platform-enterprise-license) + + +#### 请参阅 [LICENSE](LICENSE.md) 以获取有关许可的更多信息。 + +## 🍺 贡献 + +- 请给我们在 Github 上点个 :star: , 这真的很有**帮助**! +- 非常欢迎您在 [ocap repo](https://github.com/meta-d/ocap/issues) 中提交功能请求。 +- Pull requests 总是欢迎的!请将拉取请求基于 _develop_ 分支,并遵循 [贡献指南](.github/CONTRIBUTING.md)。 diff --git a/apps/api/package.json b/apps/api/package.json index 0b40e4fdc..e3a7d9afc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,8 +10,6 @@ "typeorm:flush": "yarn typeorm migration:revert", "typeorm:create": "yarn typeorm migration:create", "typeorm:preserve": "yarn typeorm:sync -- -f=ormconfig && yarn typeorm:seeds -- -f=ormconfig", - "start": "yarn ts-node -r tsconfig-paths/register --project apps/api/tsconfig.app.json src/main.ts", - "start:debug": "nodemon --config nodemon-debug.json", "build": "yarn ng build api", "build:prod": "yarn ng build api --prod", "seed": "cross-env NODE_ENV=development NODE_OPTIONS=--max_old_space_size=8192 yarn ts-node -r tsconfig-paths/register --project apps/api/tsconfig.app.json src/seed.ts", diff --git a/apps/cloud/src/app/@core/auth/auth-strategy.service.ts b/apps/cloud/src/app/@core/auth/auth-strategy.service.ts index afd0a1d6b..fadb94b4e 100644 --- a/apps/cloud/src/app/@core/auth/auth-strategy.service.ts +++ b/apps/cloud/src/app/@core/auth/auth-strategy.service.ts @@ -171,6 +171,7 @@ export class AuthStrategy extends PacAuthStrategy { this.store.userId = user.id this.store.token = token this.store.refreshToken = refreshToken + this.store.user = user return new PacAuthResult( true, diff --git a/apps/cloud/src/app/@core/guards/index.ts b/apps/cloud/src/app/@core/guards/index.ts index b7396fb99..b074c0632 100644 --- a/apps/cloud/src/app/@core/guards/index.ts +++ b/apps/cloud/src/app/@core/guards/index.ts @@ -1,2 +1,3 @@ export * from './dirty-check.guard' export * from './invite.guard' +export * from './onboard.guard' \ No newline at end of file diff --git a/apps/cloud/src/app/@core/guards/onboard.guard.ts b/apps/cloud/src/app/@core/guards/onboard.guard.ts new file mode 100644 index 000000000..938955252 --- /dev/null +++ b/apps/cloud/src/app/@core/guards/onboard.guard.ts @@ -0,0 +1,13 @@ +import { inject } from '@angular/core' +import { Router } from '@angular/router' +import { map } from 'rxjs/operators' +import { TenantService } from '../services' + +export function onboardGuard() { + const tenantService = inject(TenantService) + const router = inject(Router) + return tenantService.getOnboard().pipe( + // can onboard or navigate to home + map((onboard) => !onboard || router.navigate(['/home/'])), + ) +} diff --git a/apps/cloud/src/app/@core/interceptors/token.interceptor.ts b/apps/cloud/src/app/@core/interceptors/token.interceptor.ts index 2163752ee..3ab63b519 100644 --- a/apps/cloud/src/app/@core/interceptors/token.interceptor.ts +++ b/apps/cloud/src/app/@core/interceptors/token.interceptor.ts @@ -17,12 +17,12 @@ export class TokenInterceptor implements HttpInterceptor { // We don't want to refresh token for some requests like login or refresh token itself // So we verify url and we throw an error if it's the case if (request.url.includes('auth/refresh') || request.url.includes('login')) { - return throwError(async () => { + return throwError(() => { // We do another check to see if refresh token failed // In this case we want to logout user and to redirect it to login page if (request.url.includes('auth/refresh')) { - await firstValueFrom(this.auth.logout()) + this.auth.logout().subscribe() } return response diff --git a/apps/cloud/src/app/@core/services/app-init-service.ts b/apps/cloud/src/app/@core/services/app-init-service.ts index 74ffae1d1..b4c0f31c4 100644 --- a/apps/cloud/src/app/@core/services/app-init-service.ts +++ b/apps/cloud/src/app/@core/services/app-init-service.ts @@ -2,13 +2,14 @@ import { Injectable } from '@angular/core' import { Router } from '@angular/router' import { Ability, AbilityBuilder } from '@casl/ability' import { IUser } from '@metad/contracts' -import { ThemesEnum, UsersService } from '@metad/cloud/state' +import { UsersService } from '@metad/cloud/state' import * as Sentry from "@sentry/angular"; import { NgxPermissionsService } from 'ngx-permissions' +import { firstValueFrom } from 'rxjs' import { AuthStrategy } from '../../@core/auth/auth-strategy.service' import { Store } from '../../@core/services/store.service' -import { PACThemeService } from '../theme' import { AbilityActions, RolesEnum } from '../types' +import { TenantService } from './tenant.service' @Injectable({ providedIn: 'root' }) export class AppInitService { @@ -16,12 +17,12 @@ export class AppInitService { constructor( private readonly usersService: UsersService, + private readonly tenantService: TenantService, private readonly authStrategy: AuthStrategy, private readonly router: Router, private readonly store: Store, private readonly ngxPermissionsService: NgxPermissionsService, private readonly ability: Ability, - private readonly themeService: PACThemeService ) {} async init() { @@ -37,15 +38,10 @@ export class AppInitService { 'tenant.featureOrganizations.feature' ]) - // this.authStrategy.electronAuthentication({ - // user: this.user, - // token: this.store.token - // }); - //When a new user registers & logs in for the first time, he/she does not have tenantId. //In this case, we have to redirect the user to the onboarding page to create their first organization, tenant, role. if (!this.user?.tenantId) { - this.router.navigate(['/onboarding/tenant']) + this.router.navigate(['/onboarding/']) return } @@ -69,6 +65,12 @@ export class AppInitService { // Sentry identify user Sentry.setUser({ id: this.user.id, email: this.user.email, username: this.user.username }) + } else { + const onboarded = await firstValueFrom(this.tenantService.getOnboard()) + if (!onboarded) { + this.router.navigate(['/onboarding/']) + return + } } } catch (error) { console.log(error) diff --git a/apps/cloud/src/app/@core/services/organizations.service.ts b/apps/cloud/src/app/@core/services/organizations.service.ts index d3e5ccf09..43e68a3bc 100644 --- a/apps/cloud/src/app/@core/services/organizations.service.ts +++ b/apps/cloud/src/app/@core/services/organizations.service.ts @@ -52,7 +52,7 @@ export class OrganizationsService { ) } - demo(id: string) { - return this.http.post(`${API_PREFIX}/organization/${id}/demo`, {}) + demo(id: string, body?: any) { + return this.http.post(`${API_PREFIX}/organization/${id}/demo`, body) } } diff --git a/apps/cloud/src/app/@core/services/tenant.service.ts b/apps/cloud/src/app/@core/services/tenant.service.ts index 515709e59..955d9782c 100644 --- a/apps/cloud/src/app/@core/services/tenant.service.ts +++ b/apps/cloud/src/app/@core/services/tenant.service.ts @@ -1,19 +1,28 @@ import { HttpClient } from '@angular/common/http' -import { Injectable } from '@angular/core' +import { Injectable, inject } from '@angular/core' import { ITenant, ITenantCreateInput, ITenantSetting } from '@metad/contracts' import { API_PREFIX } from '@metad/cloud/state' -import { firstValueFrom } from 'rxjs' +import { delay, firstValueFrom, of } from 'rxjs' @Injectable({ providedIn: 'root' }) -export class PACTenantService { - constructor(private http: HttpClient) {} +export class TenantService { + + private readonly http = inject(HttpClient) API_URL = `${API_PREFIX}/tenant` create(createInput: ITenantCreateInput): Promise { return firstValueFrom(this.http.post(`${this.API_URL}`, createInput)) } + getOnboard() { + return this.http.get(`${this.API_URL}/onboard`) + } + onboard(createInput: ITenantCreateInput): Promise { + return firstValueFrom(this.http.post(`${this.API_URL}/onboard`, createInput)) + + // return firstValueFrom(of({}).pipe(delay(1000))) + } getSettings() { return firstValueFrom(this.http.get(`${API_PREFIX}/tenant-setting`)) diff --git a/apps/cloud/src/app/app-routing.module.ts b/apps/cloud/src/app/app-routing.module.ts index ba7498f41..de9d8e65f 100644 --- a/apps/cloud/src/app/app-routing.module.ts +++ b/apps/cloud/src/app/app-routing.module.ts @@ -1,12 +1,18 @@ import { NgModule } from '@angular/core' import { ExtraOptions, PreloadAllModules, RouterModule, Routes } from '@angular/router' import { SignInSuccessComponent } from './@core/auth/signin-success' +import { onboardGuard } from './@core' const routes: Routes = [ { path: 'public', loadChildren: () => import('./public/public.module').then((m) => m.PublicModule) }, + { + path: 'onboarding', + loadChildren: () => import('./onboarding/onboarding.module').then((m) => m.OnboardingModule), + canActivate: [onboardGuard] + }, { path: 'auth', loadChildren: () => import('@metad/cloud/auth').then((m) => m.PacAuthModule) diff --git a/apps/cloud/src/app/features/features-routing.module.ts b/apps/cloud/src/app/features/features-routing.module.ts index 928032c29..cc8b0675b 100644 --- a/apps/cloud/src/app/features/features-routing.module.ts +++ b/apps/cloud/src/app/features/features-routing.module.ts @@ -102,10 +102,6 @@ const routes: Routes = [ path: 'organization', loadChildren: () => import('./organization/organization.module').then((m) => m.OrganizationModule) }, - // { - // path: 'project', - // loadChildren: () => import('./project/project.module').then((m) => m.ProjectModule) - // } ] } ] diff --git a/apps/cloud/src/app/features/home/dashboard/dashboard.component.html b/apps/cloud/src/app/features/home/dashboard/dashboard.component.html index 3cdeed471..1156cc445 100644 --- a/apps/cloud/src/app/features/home/dashboard/dashboard.component.html +++ b/apps/cloud/src/app/features/home/dashboard/dashboard.component.html @@ -171,7 +171,7 @@

{{ 'PAC.MENU.HOME.HELLO' | translate: {Default: "Hello"} }}
- @@ -215,12 +215,6 @@

{{ 'PAC.MENU.HOME.HELLO' | translate: {Default: "Hello"} }}

-
- diff --git a/apps/cloud/src/app/features/home/story-widget/story-widget.component.ts b/apps/cloud/src/app/features/home/story-widget/story-widget.component.ts index 7d528f841..5ed9e0d53 100644 --- a/apps/cloud/src/app/features/home/story-widget/story-widget.component.ts +++ b/apps/cloud/src/app/features/home/story-widget/story-widget.component.ts @@ -1,11 +1,11 @@ -import { Component, Input, OnInit } from '@angular/core' -import { ActivatedRoute } from '@angular/router' +import { Component, Input, inject } from '@angular/core' +import { WidgetsService, convertStoryResult, convertStoryWidgetResult } from '@metad/cloud/state' import { WasmAgentService } from '@metad/ocap-angular/wasm-agent' import { AgentType } from '@metad/ocap-core' -import { convertStoryResult, convertStoryWidgetResult, WidgetsService } from '@metad/cloud/state' import { omit } from 'lodash-es' import { BehaviorSubject, EMPTY } from 'rxjs' import { catchError, filter, map, shareReplay, switchMap, tap } from 'rxjs/operators' +import { registerWasmAgentModel } from '../../../@core' @Component({ selector: 'pac-story-widget-feed', @@ -15,7 +15,10 @@ import { catchError, filter, map, shareReplay, switchMap, tap } from 'rxjs/opera class: 'pac-story-widget-feed' } }) -export class StoryWidgetFeedComponent implements OnInit { +export class StoryWidgetFeedComponent { + private readonly widgetsService = inject(WidgetsService) + private readonly wasmAgent = inject(WasmAgentService) + @Input() get id(): string { return this.id$.value } @@ -33,10 +36,15 @@ export class StoryWidgetFeedComponent implements OnInit { 'point.story', 'point.story.points', 'createdBy', - 'point.story.model', - 'point.story.model.indicators', - 'point.story.model.dataSource', - 'point.story.model.dataSource.type' + // 'point.story.model', + // 'point.story.model.indicators', + // 'point.story.model.dataSource', + // 'point.story.model.dataSource.type' + + 'point.story.models', + 'point.story.models.dataSource', + 'point.story.models.dataSource.type', + 'point.story.models.indicators' ]) .pipe( catchError((err) => { @@ -47,26 +55,26 @@ export class StoryWidgetFeedComponent implements OnInit { ), shareReplay(1) ) + public readonly story$ = this.widget$.pipe( map((widget) => { - const points = widget.point.story.points - points.forEach((point) => { - if (point.id === widget.pointId) { - point.widgets = [omit(widget, ['point', 'story'])] - } - }) return convertStoryResult({ ...widget.point.story, - points + points: [ + { + ...widget.point, + story: null, + widgets: [omit(widget, 'point')] + } + ] }) }), tap((story) => { - if (story.model?.agentType === AgentType.Wasm) { - this.wasmAgent.registerModel({ - ...story.model, - catalog: story.model.catalog ?? 'main' - }) - } + story.models?.forEach((model) => { + if (model.agentType === AgentType.Wasm) { + registerWasmAgentModel(this.wasmAgent, model) + } + }) }) ) @@ -77,12 +85,4 @@ export class StoryWidgetFeedComponent implements OnInit { ) public error$ = new BehaviorSubject(null) - - constructor( - private widgetsService: WidgetsService, - private wasmAgent: WasmAgentService, - private route: ActivatedRoute - ) {} - - ngOnInit() {} } diff --git a/apps/cloud/src/app/features/setting/email-templates/email-templates.component.ts b/apps/cloud/src/app/features/setting/email-templates/email-templates.component.ts index c992e6b51..cd73e9c6c 100644 --- a/apps/cloud/src/app/features/setting/email-templates/email-templates.component.ts +++ b/apps/cloud/src/app/features/setting/email-templates/email-templates.component.ts @@ -3,7 +3,6 @@ import { ChangeDetectorRef, Component, OnDestroy, - OnInit, SecurityContext, } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; @@ -41,7 +40,7 @@ export class EmailTemplatesComponent static buildForm(fb: FormBuilder): FormGroup { return fb.group({ name: [EmailTemplateNameEnum.WELCOME_USER], - languageCode: [LanguagesEnum.ENGLISH], + languageCode: [LanguagesEnum.English], subject: ['', [Validators.required, Validators.maxLength(60)]], mjml: ['', Validators.required] }); @@ -128,7 +127,7 @@ export class EmailTemplatesComponent const { tenantId } = this.store.user; const { id: organizationId } = this.organization ?? {} const { - languageCode = LanguagesEnum.ENGLISH, + languageCode = LanguagesEnum.English, name = EmailTemplateNameEnum.WELCOME_USER } = this.form.value; const result = await this.emailTemplateService.getTemplate({ diff --git a/apps/cloud/src/app/features/setting/tenant/demo/demo.component.ts b/apps/cloud/src/app/features/setting/tenant/demo/demo.component.ts index a47d95cd2..dbce5a605 100644 --- a/apps/cloud/src/app/features/setting/tenant/demo/demo.component.ts +++ b/apps/cloud/src/app/features/setting/tenant/demo/demo.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core' import { catchError, concatMap, EMPTY, Observable, tap } from 'rxjs' -import { PACTenantService, Store, ToastrService } from '../../../../@core' +import { TenantService, Store, ToastrService } from '../../../../@core' import { TranslationBaseComponent } from '../../../../@shared' import { effectAction } from '@metad/ocap-angular/core' @@ -11,7 +11,7 @@ import { effectAction } from '@metad/ocap-angular/core' }) export class DemoComponent extends TranslationBaseComponent { constructor( - private readonly tenantService: PACTenantService, + private readonly tenantService: TenantService, private readonly store: Store, private readonly _toastrService: ToastrService ) { diff --git a/apps/cloud/src/app/features/setting/tenant/settings/settings.component.ts b/apps/cloud/src/app/features/setting/tenant/settings/settings.component.ts index 2557d4f6d..a33bd3afe 100644 --- a/apps/cloud/src/app/features/setting/tenant/settings/settings.component.ts +++ b/apps/cloud/src/app/features/setting/tenant/settings/settings.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core' import { isNil } from '@metad/ocap-core' -import { PACTenantService } from '../../../../@core' +import { TenantService } from '../../../../@core' interface ItemData { id?: string @@ -18,7 +18,7 @@ export class SettingsComponent implements OnInit { editCache: { [key: string]: { edit: boolean; data: ItemData } } = {} listOfData: ItemData[] = [] - constructor(private readonly tenantService: PACTenantService, private readonly _cdr: ChangeDetectorRef) {} + constructor(private readonly tenantService: TenantService, private readonly _cdr: ChangeDetectorRef) {} async ngOnInit() { const settings = await this.tenantService.getSettings() diff --git a/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete-routing.module.ts b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete-routing.module.ts new file mode 100644 index 000000000..d38e82411 --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { OnboardingCompleteComponent } from './onboarding-complete.component'; + +const routes: Routes = [ + { + path: '', + component: OnboardingCompleteComponent + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class OnboardingCompleteRoutingModule {} diff --git a/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.html b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.scss b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.ts b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.ts new file mode 100644 index 000000000..528b523ad --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'ngm-onboarding-complete', + templateUrl: './onboarding-complete.component.html', + styleUrls: ['./onboarding-complete.component.scss'] +}) +export class OnboardingCompleteComponent {} diff --git a/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.module.ts b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.module.ts new file mode 100644 index 000000000..ef6b3ad09 --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding-complete/onboarding-complete.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core' +import { FeatureToggleModule } from '../../@shared/feature-toggle/feature-toggle.module' +import { OnboardingCompleteRoutingModule } from './onboarding-complete-routing.module' +import { OnboardingCompleteComponent } from './onboarding-complete.component' + +@NgModule({ + imports: [OnboardingCompleteRoutingModule, FeatureToggleModule], + providers: [], + declarations: [OnboardingCompleteComponent] +}) +export class OnboardingCompleteModule {} diff --git a/apps/cloud/src/app/onboarding/onboarding-routing.module.ts b/apps/cloud/src/app/onboarding/onboarding-routing.module.ts new file mode 100644 index 000000000..7e04a4e4a --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding-routing.module.ts @@ -0,0 +1,33 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' + +import { OnboardingComponent } from './onboarding.component' +import { WelcomeComponent } from './welcome/welcome.component' + +const routes: Routes = [ + { + path: '', + component: OnboardingComponent, + children: [ + { + path: '', + component: WelcomeComponent + }, + { + path: 'tenant', + loadChildren: () => import('./tenant-details/tenant-details.module').then((m) => m.TenantDetailsModule) + }, + { + path: 'complete', + loadChildren: () => + import('./onboarding-complete/onboarding-complete.module').then((m) => m.OnboardingCompleteModule) + } + ] + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class OnboardingRoutingModule {} diff --git a/apps/cloud/src/app/onboarding/onboarding.component.ts b/apps/cloud/src/app/onboarding/onboarding.component.ts new file mode 100644 index 000000000..d8e68fb2a --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding.component.ts @@ -0,0 +1,15 @@ +import { Component, inject } from '@angular/core' +import { TranslateService } from '@ngx-translate/core' + +@Component({ + selector: 'pac-onboarding', + template: ` `, + styles: [`:host { + display: flex; + width: 100%; + height: 100%; + }`], +}) +export class OnboardingComponent { + private translate = inject(TranslateService) +} diff --git a/apps/cloud/src/app/onboarding/onboarding.module.ts b/apps/cloud/src/app/onboarding/onboarding.module.ts new file mode 100644 index 000000000..41616aa95 --- /dev/null +++ b/apps/cloud/src/app/onboarding/onboarding.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core' +import { MatBottomSheetModule } from '@angular/material/bottom-sheet' +import { FormlyModule } from '@ngx-formly/core' +import { FormlyMaterialModule } from '@ngx-formly/material' +import { ServerAgent } from '../@core' +import { OnboardingRoutingModule } from './onboarding-routing.module' +import { OnboardingComponent } from './onboarding.component' + +@NgModule({ + imports: [OnboardingRoutingModule, FormlyModule.forRoot(), FormlyMaterialModule, MatBottomSheetModule], + declarations: [OnboardingComponent], + providers: [ServerAgent] +}) +export class OnboardingModule {} diff --git a/apps/cloud/src/app/onboarding/tenant-details/tenant-details-routing.module.ts b/apps/cloud/src/app/onboarding/tenant-details/tenant-details-routing.module.ts new file mode 100644 index 000000000..3a1d16575 --- /dev/null +++ b/apps/cloud/src/app/onboarding/tenant-details/tenant-details-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { TenantDetailsComponent } from './tenant-details.component' + +const routes: Routes = [ + { + path: '', + component: TenantDetailsComponent + } +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class TenantDetailsRoutingModule {} diff --git a/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.html b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.html new file mode 100644 index 000000000..af3c7ea53 --- /dev/null +++ b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.html @@ -0,0 +1,187 @@ +
+ +
+ + + + +
+ + {{ 'PAC.Onboarding.WhatsPreferredLanguage' | translate: {Default: 'What\'s your preferred Language'} }} + + +

{{ 'PAC.Onboarding.PreferredLanguageDescription' | translate: {Default: 'This language will be used throughout Metad analytics platform and will be the default for new users.'} }}

+ + + + {{ 'PAC.Languages.' + language | translate: {Default: language } }} + + + +
+ +
+
+
+ +
+ {{ 'PAC.Onboarding.WhatCallYou' | translate: {Default: 'What should we call you'} }}? + +
+ + {{ 'PAC.Onboarding.FirstName' | translate: {Default: 'First name'} }} + + + + + {{ 'PAC.Onboarding.LastName' | translate: {Default: 'Last name'} }} + + + + + {{ 'PAC.Onboarding.Email' | translate: {Default: 'Email'} }} + + + + + {{ 'PAC.Onboarding.CompanyTeamName' | translate: {Default: 'Company or team name'} }} + + + + + {{ 'PAC.Onboarding.CreateAPassword' | translate: {Default: 'Create a password'} }} + + + {{ 'PAC.Onboarding.Minlength' | translate: {Default: 'Min length'} }} {{error.requiredLength}} {{ 'PAC.Onboarding.Actuallength' | translate: {Default: 'actual length'} }} {{error.actualLength}} + + + + {{ 'PAC.Onboarding.ConfirmYourPassword' | translate: {Default: 'Confirm your password'} }} + + {{ 'PAC.Onboarding.PasswordMustMatch' | translate: {Default: 'Password must match'} }} + +
+ +
+ +
+
+
+ + + {{ 'PAC.Onboarding.WantGenerateDemo' | translate: {Default: 'Do you want to generate samples'} }}? + +
{{ 'PAC.Onboarding.GenerateDemoDescription' | translate: {Default: 'The system will download the demo data file from the server link, import the data into the system database, and create the corresponding samples'} }}.
+ +
+ + + GitHub + Aliyun oss + +
+ +
+ + {{demoError()}} +
+ +
+ + +
+
+ + + {{ 'PAC.Onboarding.AddYourData' | translate: {Default: 'Add your data'} }} + +
+
+

{{ 'PAC.Onboarding.ReadyExploringYourData' | translate: {Default: 'Are you ready to start exploring your data? Add it below.'} }}

+

{{ 'PAC.Onboarding.NotReadySample' | translate: {Default: 'Not ready? Skip and play around with our Samples.'} }}

+
+ + +
+
+ + + + + {{item.name}} + + + +
+ + +
+ +
{{type.name}}
+
+ + + {{ 'PAC.Onboarding.DisplayName' | translate: {Default: 'Display Name'} }} + + + + {{ 'PAC.Onboarding.NameRequired' | translate: {Default: 'Name Required'} }} + + + + + +
+
+ +
+ + +
+ + +
+
+ + + {{ 'PAC.Onboarding.Done' | translate: {Default: 'Done'} }} +
+
{{ 'PAC.Onboarding.AllSetup' | translate: {Default: 'You\'re all set up!'} }}
+ + + +
+ +
+
+
+
+ +
+ {{ 'PAC.Onboarding.FeelStuck' | translate: {Default: 'If you feel stuck'} }} + , + {{ 'PAC.Onboarding.OurGuide' | translate: {Default: 'our getting started guide'} }} + {{ 'PAC.Onboarding.ClickAway' | translate: {Default: 'is just a click away'} }}. +
\ No newline at end of file diff --git a/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.scss b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.scss new file mode 100644 index 000000000..c25792c8e --- /dev/null +++ b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.scss @@ -0,0 +1,3 @@ +:host { + @apply flex-1 flex flex-col justify-start items-center overflow-auto; +} \ No newline at end of file diff --git a/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.ts b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.ts new file mode 100644 index 000000000..8c7329285 --- /dev/null +++ b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.component.ts @@ -0,0 +1,267 @@ +import { CommonModule } from '@angular/common' +import { Component, ViewChild, inject, signal } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatDividerModule } from '@angular/material/divider' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { MatListModule } from '@angular/material/list' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import { MatRadioModule } from '@angular/material/radio' +import { MatStepper, MatStepperModule } from '@angular/material/stepper' +import { Router } from '@angular/router' +import { DataSourceService, DataSourceTypesService } from '@metad/cloud/state' +import { NgmCommonModule } from '@metad/ocap-angular/common' +import { omit } from '@metad/ocap-core' +import { FormlyModule } from '@ngx-formly/core' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import { BehaviorSubject, combineLatest, firstValueFrom, map, startWith } from 'rxjs' +import { + AuthStrategy, + BonusTypeEnum, + CurrenciesEnum, + DEFAULT_TENANT, + DefaultValueDateTypeEnum, + IDataSourceType, + IOrganization, + LanguagesEnum, + MatchValidator, + OrganizationsService, + ServerAgent, + TenantService, + ToastrService, + convertConfigurationSchema, + getErrorMessage +} from '../../@core' + +@Component({ + standalone: true, + selector: 'ngm-tenant-details', + templateUrl: './tenant-details.component.html', + styleUrls: ['./tenant-details.component.scss'], + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + MatStepperModule, + MatFormFieldModule, + MatButtonModule, + MatInputModule, + MatListModule, + MatDividerModule, + MatProgressSpinnerModule, + MatRadioModule, + FormlyModule, + + NgmCommonModule + ] +}) +export class TenantDetailsComponent { + private readonly tenantService = inject(TenantService) + private readonly typesService = inject(DataSourceTypesService) + private readonly dataSourceService = inject(DataSourceService) + private readonly organizationsService = inject(OrganizationsService) + private readonly serverAgent? = inject(ServerAgent, { optional: true }) + private readonly authStrategy = inject(AuthStrategy) + private readonly _formBuilder = inject(FormBuilder) + private readonly router = inject(Router) + private readonly translateService = inject(TranslateService) + private readonly toastrService = inject(ToastrService) + + @ViewChild('stepper') stepper: MatStepper + + Languages = Object.values(LanguagesEnum) + + preferredLanguageFormGroup: FormGroup = this._formBuilder.group({ preferredLanguage: ['', [Validators.required]] }) + userFormGroup: FormGroup = this._formBuilder.group( + { + firstName: [''], + lastName: [''], + email: ['', [Validators.required, Validators.email]], + organizationName: ['', [Validators.required]], + password: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', [Validators.required, Validators.minLength(8)]] + }, + { + validators: [MatchValidator.mustMatch('password', 'confirmPassword')] + } + ) + demoFormGroup: FormGroup = this._formBuilder.group({ + source: ['github', Validators.required] + }) + + dataSourceTypeFormGroup: FormGroup = this._formBuilder.group({ + type: [null, [Validators.required]], + name: [null, [Validators.required]] + }) + get type() { + return this.dataSourceTypeFormGroup.get('type').value?.[0] + } + + defaultOrganization = signal(null) + + loading = signal(false) + tenantCompleted = signal(false) + demoError = signal(null) + demoCompleted = signal(false) + connectionCompleted = signal(false) + + searchControl = new FormControl() + private readonly dataSourceTypes$ = new BehaviorSubject([]) + public readonly filteredDataSourceTypes = toSignal( + combineLatest([this.dataSourceTypes$, this.searchControl.valueChanges.pipe(startWith(''))]).pipe( + map(([types, search]) => + search ? types.filter((type) => type.name.toLowerCase().includes(search.toLowerCase())) : types + ) + ) + ) + + formlyFields = toSignal( + combineLatest([ + this.translateService.stream('PAC.DataSources.Schema'), + this.dataSourceTypeFormGroup.get('type').valueChanges + ]).pipe(map(([i18n, type]) => convertConfigurationSchema(type[0].configuration, i18n))) + ) + + model = {} + + minlengthError() { + return this.userFormGroup.get('password').getError('minlength') + } + + mustMatchError() { + return this.userFormGroup.get('confirmPassword').getError('mustMatch') + } + + dataSourceNameError() { + return this.dataSourceTypeFormGroup.get('name').getError('required') + } + + async onboard() { + this.loading.set(true) + try { + const tenant = await this.tenantService.onboard({ + name: DEFAULT_TENANT, + superAdmin: { + firstName: this.userFormGroup.get('firstName').value, + lastName: this.userFormGroup.get('lastName').value, + email: this.userFormGroup.get('email').value, + hash: this.userFormGroup.get('password').value, + preferredLanguage: this.preferredLanguageFormGroup.get('preferredLanguage').value[0] + }, + defaultOrganization: { + name: this.userFormGroup.get('organizationName').value, + preferredLanguage: this.preferredLanguageFormGroup.get('preferredLanguage').value[0], + currency: CurrenciesEnum.USD, + profile_link: '', + imageUrl: '', + isDefault: true, + client_focus: '', + defaultValueDateType: DefaultValueDateTypeEnum.TODAY, + bonusType: BonusTypeEnum.PROFIT_BASED_BONUS, + tenant: null + } + }) + + this.tenantCompleted.set(true) + this.loading.set(false) + this.defaultOrganization.set(tenant.organizations[0]) + } catch (error) { + this.loading.set(false) + this.toastrService.error(getErrorMessage(error)) + return + } + + this.stepper.next() + await this.afterOnboard() + } + + async afterOnboard() { + await firstValueFrom( + this.authStrategy.login({ + email: this.userFormGroup.get('email').value, + password: this.userFormGroup.get('password').value + }) + ) + + this.dataSourceTypes$.next(await firstValueFrom(this.typesService.getAll())) + } + + async generateDemo() { + try { + this.demoError.set(null) + this.loading.set(true) + await firstValueFrom( + this.organizationsService.demo(this.defaultOrganization().id, { + source: this.demoFormGroup.get('source').value + }) + ) + + this.toastrService.success('PAC.Onboarding.GenerateDemoSuccess', { + Default: 'Demo data & samples generated successfully!' + }) + this.demoCompleted.set(true) + this.loading.set(false) + this.stepper.next() + } catch (error) { + this.loading.set(false) + const errorText = getErrorMessage(error) + this.demoError.set(errorText) + this.toastrService.error(errorText) + } + } + + navigateHome() { + this.router.navigate(['home']) + } + + compareTypeFn(a: IDataSourceType, b: IDataSourceType) { + return a?.id === b?.id + } + + onModelChange(event) { + // console.log(event) + } + + async connectDatabase() { + this.loading.set(true) + const dataSource = { + name: this.dataSourceTypeFormGroup.value.name, + type: this.type, + options: { + ...omit(this.dataSourceTypeFormGroup.value, 'type', 'name') + } + } + try { + await this.serverAgent.request( + { + type: this.type.protocol.toUpperCase(), + dataSource: { + ...this.dataSourceTypeFormGroup.value, + typeId: this.type.id + } + }, + { + method: 'get', + url: 'ping', + body: dataSource + } + ) + + this.toastrService.success('PAC.ACTIONS.PING', { Default: 'Ping' }) + + // Create datadource + this.dataSourceService + const result = await firstValueFrom(this.dataSourceService.create(dataSource)) + this.toastrService.success('PAC.MESSAGE.CreateDataSource', { Default: 'Create data source' }) + this.loading.set(false) + this.connectionCompleted.set(true) + this.stepper.next() + } catch (err) { + const message = getErrorMessage(err) + this.loading.set(false) + this.toastrService.error(message) + } + } +} diff --git a/apps/cloud/src/app/onboarding/tenant-details/tenant-details.module.ts b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.module.ts new file mode 100644 index 000000000..7f990e424 --- /dev/null +++ b/apps/cloud/src/app/onboarding/tenant-details/tenant-details.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core' +import { TenantDetailsRoutingModule } from './tenant-details-routing.module' + +@NgModule({ + imports: [TenantDetailsRoutingModule], + providers: [], + declarations: [] +}) +export class TenantDetailsModule {} diff --git a/apps/cloud/src/app/onboarding/welcome/_welcome.component.scss b/apps/cloud/src/app/onboarding/welcome/_welcome.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cloud/src/app/onboarding/welcome/welcome.component.html b/apps/cloud/src/app/onboarding/welcome/welcome.component.html new file mode 100644 index 000000000..2067422c7 --- /dev/null +++ b/apps/cloud/src/app/onboarding/welcome/welcome.component.html @@ -0,0 +1,20 @@ +
+ +
+ {{ 'PAC.Onboarding.Welcome' | translate: {Default: 'Welcome to Metad Analytics Platform'} }} +
+
+ {{ 'PAC.Onboarding.WelcomeInfo' | translate: {Default: 'Looks like everything is working. Now let\'s get to know you, connect to your data, and start finding you some answers!'} }} +
+ + +
+ +
+ {{ 'PAC.Onboarding.FeelStuck' | translate: {Default: 'If you feel stuck'} }} + , + {{ 'PAC.Onboarding.OurGuide' | translate: {Default: 'our getting started guide'} }} + {{ 'PAC.Onboarding.ClickAway' | translate: {Default: 'is just a click away'} }}. +
diff --git a/apps/cloud/src/app/onboarding/welcome/welcome.component.scss b/apps/cloud/src/app/onboarding/welcome/welcome.component.scss new file mode 100644 index 000000000..a11c5533c --- /dev/null +++ b/apps/cloud/src/app/onboarding/welcome/welcome.component.scss @@ -0,0 +1,3 @@ +:host { + @apply relative w-full h-full flex flex-col justify-center items-center; +} \ No newline at end of file diff --git a/apps/cloud/src/app/onboarding/welcome/welcome.component.ts b/apps/cloud/src/app/onboarding/welcome/welcome.component.ts new file mode 100644 index 000000000..92e07b671 --- /dev/null +++ b/apps/cloud/src/app/onboarding/welcome/welcome.component.ts @@ -0,0 +1,21 @@ +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { Router } from '@angular/router' +import { TranslateModule, TranslateService } from '@ngx-translate/core' + +@Component({ + standalone: true, + selector: 'ngm-onboarding-welcome', + templateUrl: './welcome.component.html', + styleUrls: ['./welcome.component.scss'], + imports: [MatButtonModule, TranslateModule] +}) +export class WelcomeComponent { + private translate = inject(TranslateService) + private router = inject(Router) + private route = inject(Router) + + navigateTenant() { + this.router.navigate(['onboarding', 'tenant']) + } +} diff --git a/apps/cloud/src/assets/i18n/en.json b/apps/cloud/src/assets/i18n/en.json index df6b006bd..0561bfd90 100644 --- a/apps/cloud/src/assets/i18n/en.json +++ b/apps/cloud/src/assets/i18n/en.json @@ -124,6 +124,14 @@ "STATUS": "Status" } }, + "Languages": { + "en": "English", + "en-US": "US English", + "zh": "Chinese", + "zh-CN": "Chinese", + "zh-Hans": "Simplied Chinese", + "zh-Hant": "Traditional Chinese" + }, "title": { "short": "Analytics Cloud" }, diff --git a/apps/cloud/src/assets/i18n/zh-CN.json b/apps/cloud/src/assets/i18n/zh-CN.json index b8a37735e..7322a6692 100644 --- a/apps/cloud/src/assets/i18n/zh-CN.json +++ b/apps/cloud/src/assets/i18n/zh-CN.json @@ -1135,6 +1135,52 @@ "BUSINESS_AREA": "业务域", "INDICATOR": "指标" }, + "Onboarding": { + "Welcome": "欢迎使用 Metad 分析平台", + "WelcomeInfo": "看起来一切都正常。 现在让我们认识您,连接到你的数据,并开始为您寻找一些答案!", + "LetGetStarted": "让我们开始吧", + "FeelStuck": "如果您感到困惑", + "OurGuide": "我们的入门指南", + "ClickAway": "只需一次点击即可获得", + "WhatsPreferredLanguage": "您更喜欢哪种语言?", + "PreferredLanguageDescription": "这种语言将会被用于Metad分析平台中,并且将会是新用户的默认语言。", + "Next": "下一步", + "WhatCallYou": "我们应该怎么称呼您", + "CompanyTeamName": "公司或组织名称", + "CreateAPassword": "创建一个密码", + "Email": "邮箱", + "FirstName": "名", + "LastName": "姓", + "Minlength": "最小长度", + "Actuallength": "实际长度", + "ConfirmYourPassword": "确认密码", + "PasswordMustMatch": "密码不一致", + "WantGenerateDemo": "是否想要生成演示数据", + "GenerateDemoDescription": "系统将从服务器链接上下载演示数据文件,并将数据导入至系统数据库中,然后创建相应的演示样例", + "Skip": "跳过", + "Generate": "生成", + "Retry": "重试", + "AddYourData": "添加您的数据", + "ReadyExploringYourData": "准备好开始探索您的数据了吗?在下面添加它吧。", + "NotReadySample": "还没有准备好?跳过并玩玩我们的示例吧。", + "DisplayName": "显示名称", + "NameRequired": "名称不能为空", + "AddDataLater": "我稍后添加我的数据", + "ConnectDatabase": "连接数据库", + "Done": "完成", + "AllSetup": "您已经设置好了!", + "TakeMetoMetadAnalyticsPlatform": "带我去 Metad 分析平台", + "SelectNetwork": "选择所需网络", + "GenerateDemoSuccess": "演示数据和样例生成成功!" + }, + "Languages": { + "en": "英文", + "en-US": "美国英文", + "zh": "中文", + "zh-CN": "中文", + "zh-Hans": "中文简体", + "zh-Hant": "中文繁体" + }, "title": { "short": "分析云" }, diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml new file mode 100644 index 000000000..bfaaf5f38 --- /dev/null +++ b/docker-compose.demo.yml @@ -0,0 +1,144 @@ +version: '3.7' + +volumes: + postgres_data: + certificates: + +networks: + overlay: + driver: bridge + +services: + db: + container_name: db + image: postgres:12-alpine + restart: always + environment: + POSTGRES_DB: ${DB_NAME:-postgres} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASS:-root} + healthcheck: + test: + [ + 'CMD-SHELL', + 'psql postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@localhost:5432/$${POSTGRES_DB} || exit 1' + ] + volumes: + - postgres_data:/var/lib/postgresql/data + - ./.scripts/initdb.d/:/docker-entrypoint-initdb.d/:ro + networks: + - overlay + + adminer: + container_name: adminer + image: adminer + restart: always + depends_on: + - db + links: + - db:${DB_HOST:-db} + environment: + ADMINER_DEFAULT_DB_DRIVER: pgsql + ADMINER_DEFAULT_DB_HOST: ${DB_HOST:-db} + ADMINER_DEFAULT_DB_NAME: ${DB_NAME:-postgres} + ADMINER_DEFAULT_DB_PASSWORD: ${DB_PASS:-root} + ports: + - '8084:8080' + networks: + - overlay + + redis: + container_name: redis + image: redis:6-alpine + mem_limit: 100m + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'redis-cli -h localhost -p 6379 PING'] + interval: 1s + timeout: 30s + command: ["sh", "-c", "redis-server --requirepass $${REDIS_PASSWORD}"] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + volumes: + - ./.deploy/redis/data:/data + networks: + - overlay + + # OLAP Services + olap: + container_name: olap + image: metadc/metad-olap:1.0.0 + mem_limit: 1024m + restart: always + environment: + OLAP_REDIS_DATABASE: 1 + OLAP_REDIS_HOST: "redis" + OLAP_REDIS_PORT: 6379 + OLAP_REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - overlay + + api: + container_name: api + image: registry.cn-hangzhou.aliyuncs.com/metad/metad-api:1.6.10 + environment: + HOST: ${API_HOST:-api} + PORT: ${API_PORT:-3000} + NODE_ENV: ${NODE_ENV:-development} + DB_HOST: db + REDIS_HOST: redis + REDIS_PORT: 6379 + OLAP_HOST: olap + OLAP_PORT: 8080 + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + SENTRY_DSN: ${SENTRY_DSN:-} + env_file: + - .env + command: ['node', 'main.js'] + restart: on-failure + depends_on: + - db + - redis + links: + - db:${DB_HOST:-db} + ports: + - '3000:${API_PORT:-3000}' + networks: + - overlay + + webapp: + container_name: webapp + image: registry.cn-hangzhou.aliyuncs.com/metad/metad-webapp:1.6.10 + environment: + HOST: ${WEB_HOST:-webapp} + PORT: ${WEB_PORT:-4200} + NODE_ENV: ${NODE_ENV:-development} + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200} + SENTRY_DSN: ${SENTRY_DSN:-} + CHATWOOT_SDK_TOKEN: ${CHATWOOT_SDK_TOKEN:-} + CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-} + CLOUDINARY_API_KEY: ${CLOUDINARY_API_KEY:-} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} + GOOGLE_PLACE_AUTOCOMPLETE: ${GOOGLE_PLACE_AUTOCOMPLETE:-false} + DEFAULT_LATITUDE: ${DEFAULT_LATITUDE:-42.6459136} + DEFAULT_LONGITUDE: ${DEFAULT_LONGITUDE:-23.3332736} + DEFAULT_CURRENCY: ${DEFAULT_CURRENCY:-USD} + DEMO: 'true' + API_HOST: ${API_HOST:-api} + API_PORT: ${API_PORT:-3000} + entrypoint: './entrypoint.compose.sh' + command: ['nginx', '-g', 'daemon off;'] + env_file: + - .env + restart: on-failure + links: + - db:${DB_HOST:-db} + - api:${API_HOST:-api} + depends_on: + - db + - api + ports: + - 4200:80 + networks: + - overlay diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 664df5813..1a409a1f3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,8 +9,8 @@ networks: services: db: - image: postgres:12-alpine container_name: db + image: postgres:12-alpine restart: always environment: POSTGRES_DB: ${DB_NAME:-postgres} @@ -31,8 +31,8 @@ services: - overlay adminer: - image: adminer container_name: adminer + image: adminer restart: always depends_on: - db @@ -47,6 +47,7 @@ services: - overlay redis: + container_name: redis image: redis:6-alpine mem_limit: 100m restart: unless-stopped @@ -59,6 +60,7 @@ services: - overlay redisinsight: + container_name: redisinsight image: redislabs/redisinsight:1.11.1 ports: - "8001:8001" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..4a3bea2f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,185 @@ +version: '3.7' + +volumes: + postgres_data: + certificates: + clickhouse_data: + +networks: + overlay: + driver: bridge + +services: + db: + image: postgres:12-alpine + container_name: db + restart: always + environment: + POSTGRES_DB: ${DB_NAME:-postgres} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASS:-root} + healthcheck: + test: + [ + 'CMD-SHELL', + 'psql postgres://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@localhost:5432/$${POSTGRES_DB} || exit 1' + ] + volumes: + - postgres_data:/var/lib/postgresql/data + - ./.scripts/initdb.d/:/docker-entrypoint-initdb.d/:ro + networks: + - overlay + + adminer: + image: adminer + container_name: adminer + restart: always + depends_on: + - db + links: + - db:${DB_HOST:-db} + environment: + ADMINER_DEFAULT_DB_DRIVER: pgsql + ADMINER_DEFAULT_DB_HOST: ${DB_HOST:-db} + ADMINER_DEFAULT_DB_NAME: ${DB_NAME:-postgres} + ADMINER_DEFAULT_DB_PASSWORD: ${DB_PASS:-root} + ports: + - '8084:8080' + networks: + - overlay + + # Clickhouse Database + clickhouse: + image: yandex/clickhouse-server + mem_limit: 2048m + ports: + - 9000:9000 + - 8123:8123 + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./.deploy/clickhouse/log:/var/log/clickhouse-server/ + ulimits: + nofile: + soft: 262144 + hard: 262144 + privileged: true + networks: + - overlay + + redis: + image: redis:6-alpine + mem_limit: 100m + restart: unless-stopped + command: ["sh", "-c", "redis-server --requirepass $${REDIS_PASSWORD}"] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - overlay + + # OLAP Services + olap: + build: + context: . + dockerfile: ./.deploy/olap/Dockerfile + target: webapp + mem_limit: 1024m + restart: always + environment: + REDIS_DATABASE: 1 + REDIS_HOST: "redis" + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + networks: + - overlay + + api: + container_name: api + image: metadc/metad-api:latest + build: + context: . + dockerfile: ./.deploy/api/Dockerfile + target: production + args: + NODE_ENV: ${NODE_ENV:-development} + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + environment: + HOST: ${API_HOST:-api} + PORT: ${API_PORT:-3000} + NODE_ENV: ${NODE_ENV:-development} + DB_HOST: db + REDIS_HOST: redis + REDIS_PORT: 6379 + OLAP_HOST: olap + OLAP_PORT: 8080 + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + SENTRY_DSN: ${SENTRY_DSN:-} + env_file: + - .env + command: ['node', 'main.js'] + restart: on-failure + depends_on: + - db + - redis + links: + - db:${DB_HOST:-db} + ports: + - '3000:${API_PORT:-3000}' + networks: + - overlay + webapp: + container_name: webapp + image: metadc/metad-webapp:latest + build: + context: . + dockerfile: ./.deploy/webapp/Dockerfile + target: production + args: + NODE_ENV: ${NODE_ENV:-development} + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200} + SENTRY_DSN: ${SENTRY_DSN:-} + CHATWOOT_SDK_TOKEN: ${CHATWOOT_SDK_TOKEN:-} + CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-} + CLOUDINARY_API_KEY: ${CLOUDINARY_API_KEY:-} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} + GOOGLE_PLACE_AUTOCOMPLETE: ${GOOGLE_PLACE_AUTOCOMPLETE:-false} + DEFAULT_LATITUDE: ${DEFAULT_LATITUDE:-42.6459136} + DEFAULT_LONGITUDE: ${DEFAULT_LONGITUDE:-23.3332736} + DEFAULT_CURRENCY: ${DEFAULT_CURRENCY:-USD} + DEMO: 'true' + API_HOST: ${API_HOST:-api} + API_PORT: ${API_PORT:-3000} + environment: + HOST: ${WEB_HOST:-webapp} + PORT: ${WEB_PORT:-4200} + NODE_ENV: ${NODE_ENV:-development} + API_BASE_URL: ${API_BASE_URL:-http://localhost:3000} + CLIENT_BASE_URL: ${CLIENT_BASE_URL:-http://localhost:4200} + SENTRY_DSN: ${SENTRY_DSN:-} + CHATWOOT_SDK_TOKEN: ${CHATWOOT_SDK_TOKEN:-} + CLOUDINARY_CLOUD_NAME: ${CLOUDINARY_CLOUD_NAME:-} + CLOUDINARY_API_KEY: ${CLOUDINARY_API_KEY:-} + GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-} + GOOGLE_PLACE_AUTOCOMPLETE: ${GOOGLE_PLACE_AUTOCOMPLETE:-false} + DEFAULT_LATITUDE: ${DEFAULT_LATITUDE:-42.6459136} + DEFAULT_LONGITUDE: ${DEFAULT_LONGITUDE:-23.3332736} + DEFAULT_CURRENCY: ${DEFAULT_CURRENCY:-USD} + DEMO: 'true' + API_HOST: ${API_HOST:-api} + API_PORT: ${API_PORT:-3000} + entrypoint: './entrypoint.compose.sh' + command: ['nginx', '-g', 'daemon off;'] + env_file: + - .env + restart: on-failure + links: + - db:${DB_HOST:-db} + - api:${API_HOST:-api} + depends_on: + - db + - api + ports: + - 4200:80 + - 4300:8080 + networks: + - overlay diff --git a/libs/apps/state/src/lib/data-source-types.service.ts b/libs/apps/state/src/lib/data-source-types.service.ts index 8f4ed4c3c..75c05a699 100644 --- a/libs/apps/state/src/lib/data-source-types.service.ts +++ b/libs/apps/state/src/lib/data-source-types.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http' -import { Injectable } from '@angular/core' +import { Injectable, inject } from '@angular/core' import { IDataSourceType } from '@metad/contracts' import { map, shareReplay } from 'rxjs/operators' import { API_DATA_SOURCE_TYPE } from './constants' @@ -11,9 +11,9 @@ import { API_DATA_SOURCE_TYPE } from './constants' }) export class DataSourceTypesService { - public readonly types$ = this.getAll().pipe(shareReplay(1)) + private readonly httpClient = inject(HttpClient) - constructor(public httpClient: HttpClient) {} + public readonly types$ = this.getAll().pipe(shareReplay(1)) getAll() { return this.httpClient.get<{ items: Array }>(API_DATA_SOURCE_TYPE).pipe(map(({ items }) => items)) diff --git a/nodemon-debug.json b/nodemon-debug.json new file mode 100644 index 000000000..e8c0759b5 --- /dev/null +++ b/nodemon-debug.json @@ -0,0 +1,6 @@ +{ + "ignore": [".git", "node_modules/", "dist/", "coverage/", "**/*.spec.ts"], + "watch": ["apps/api/src", "packages/server/src", "packages/auth/src", "packages/analytics/src"], + "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register -r tsconfig-paths/register --project apps/api/tsconfig.app.json apps/api/src/main.ts", + "ext": "ts" +} diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 000000000..5685e43b6 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,11 @@ +{ + "restartable": "rs", + "ignore": [".git", "node_modules/", "dist/", "coverage/", "**/*.spec.ts"], + "watch": ["apps/api/src", "packages/server/src", "packages/auth/src", "packages/analytics/src"], + "exec": "yarn ts-node -r tsconfig-paths/register --project apps/api/tsconfig.app.json apps/api/src/main.ts", + "env": { + "NODE_ENV": "development", + "LOGGER_LEVEL": "debug" + }, + "ext": "ts" +} diff --git a/package.json b/package.json index d6f57fbda..064983c6c 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "ocap", "author": "Metad", "version": "0.4.0-rc.1", - "license": "MIT", "scripts": { + "start": "concurrently \"yarn start:api\" \"yarn start:olap\" \"yarn start:cloud\"", "start:cloud": "nx serve cloud --disableHostCheck --host 0.0.0.0", "start:analytics": "yarn --cwd ./packages/analytics start", "start:angular": "nx serve --project ng --port 4400 --disableHostCheck --host 0.0.0.0", @@ -12,7 +12,8 @@ "start:nest": "nx serve --project nest", "start:olap": "yarn --cwd ./packages/olap start", "start:api": "yarn build:package:all && yarn nx serve api", - "start:dev": "concurrently \"yarn start:api\" \"yarn start:olap\" \"yarn start:cloud\"", + "start:api:dev": "nodemon", + "start:api:debug": "nodemon --config nodemon-debug.json", "storybook": "concurrently \"yarn:watch:*\"", "watch:tailwind": "npx tailwindcss -c packages/angular/tailwind.config.js -i ./packages/angular/_index.scss -o ./packages/angular/.storybook/index.css -m1 --watch", "watch:storybook": "npx nx storybook angular", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 048ee5efa..4d1de79d1 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -30,6 +30,7 @@ "sharp": "^0.30.5", "socket.io-redis": "6.1.1", "typeorm": "^0.2.37", + "unzipper": "^0.10.14", "xml2js": "^0.4.23" }, "devDependencies": { diff --git a/packages/analytics/src/core/events/handlers/index.ts b/packages/analytics/src/core/events/handlers/index.ts index 98d18aacd..fd0dfdc42 100644 --- a/packages/analytics/src/core/events/handlers/index.ts +++ b/packages/analytics/src/core/events/handlers/index.ts @@ -1,6 +1,5 @@ import { OrganizationDemoHandler } from "./organization.demo.handler" -import { TenantCreatedHandler } from "./tenant.created.handler" import { TrialUserCreatedHandler } from "./trial.created.handler" -export const EventHandlers = [TrialUserCreatedHandler, TenantCreatedHandler] +export const EventHandlers = [TrialUserCreatedHandler] export const CommandHandlers = [OrganizationDemoHandler] diff --git a/packages/analytics/src/core/events/handlers/organization.demo.handler.ts b/packages/analytics/src/core/events/handlers/organization.demo.handler.ts index 896be9520..6c44c320c 100644 --- a/packages/analytics/src/core/events/handlers/organization.demo.handler.ts +++ b/packages/analytics/src/core/events/handlers/organization.demo.handler.ts @@ -17,13 +17,15 @@ import { import { pick } from '@metad/server-common' import { ConfigService, getConnectionOptions } from '@metad/server-config' import { Organization, OrganizationDemoCommand, RequestContext } from '@metad/server-core' -import { Inject } from '@nestjs/common' +import { Inject, Logger } from '@nestjs/common' import { ConfigService as NestConfigService } from '@nestjs/config' import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs' import { InjectRepository } from '@nestjs/typeorm' import * as fs from 'fs' -import { assign, isString } from 'lodash' import * as path from 'path' +import * as unzipper from 'unzipper' +import * as _axios from 'axios' +import { assign, isString } from 'lodash' import { RedisClientType } from 'redis' import { Repository } from 'typeorm' import { dataLoad, prepareDataSource } from '../../../data-source/utils' @@ -42,8 +44,12 @@ import { import { readYamlFile } from '../../helper' import { REDIS_CLIENT } from '../../redis.module' +const axios = _axios.default + @CommandHandler(OrganizationDemoCommand) export class OrganizationDemoHandler implements ICommandHandler { + private readonly logger = new Logger(OrganizationDemoHandler.name) + tenant: ITenant organization: Organization owner: IUser @@ -79,49 +85,27 @@ export class OrganizationDemoHandler implements ICommandHandler { + const { id, options } = command.input const userId = RequestContext.currentUserId() - const organization = await this.orgRepository.findOne(command.input.id, { relations: ['tenant'] }) + const organization = await this.orgRepository.findOne(id, { relations: ['tenant'] }) this.organization = organization this.tenant = organization.tenant this.owner = RequestContext.currentUser() - const idDemo = this.configService.get('demo') + const isDemo = this.configService.get('demo') as boolean const withDoris = this.nestConfigService.get('INSTALLATION_MODE') === 'with-doris' const withStarrocks = this.nestConfigService.get('INSTALLATION_MODE') === 'with-starrocks' const standalone = !this.nestConfigService.get('INSTALLATION_MODE') || this.nestConfigService.get('INSTALLATION_MODE') === 'standalone' - console.log( - 'Generate demo data for tenant', - organization.tenantId, - 'organzation', - organization.id, - 'user', - userId - ) - - // await seedTenantDefaultData( - // this.dstService, - // this.dsRepository, - // this.businessAreaRepository, - // this.businessAreaUserRepository, - // this.modelRepository, - // this.modelService, - // this.storyRepository, - // this.storyPointRepository, - // this.storyWidgetRepository, - // this.indicatorRepository, - // organization.tenantId, - // userId, - // organization.id, - // this.commandBus - // ) - - const demosFolder = path.join(this.configService.assetOptions.assetPath, 'demos') - const files = fs.readdirSync(demosFolder).filter((file) => { - return path.extname(file).toLowerCase() === '.yml' - }) + this.logger.log(`Generate demo data for tenant ${organization.tenantId}, organzation ${organization.id}, user ${userId}`) + + //extracted import data files directory path + const samplesPath = await this.getSamplesPath() + const demosFolder = path.join(samplesPath, 'demos') + const file = options?.source === 'aliyun' ? 'https://metad-oss.oss-cn-shanghai.aliyuncs.com/ocap/demos-v0.4.0.zip' : 'https://github.com/meta-d/samples/raw/main/ocap/demos-v0.4.0.zip' + const files = await this.unzipAndRead(file, samplesPath) - console.log(`Read demos files: `, files) + this.logger.debug(files) for await (const file of files) { const sheets = await readYamlFile< @@ -177,7 +161,7 @@ export class OrganizationDemoHandler implements ICommandHandler { + return path.extname(file).toLowerCase() === '.yml' + }) + return files + } + + async downloadDemoFile(url: string, destination: string) { + this.logger.debug(`download demo file from ${url} to ${destination}`) + + const writer = fs.createWriteStream(destination) + const response = await axios({ + url, + method: 'GET', + responseType: 'stream' + }) + + response.data.pipe(writer) + + return new Promise((resolve, reject) => { + writer.on('finish', resolve) + writer.on('error', reject) + }) + } + async createDorisDataSource(_dataSource: IDataSource): Promise { // Remove existing data sources with the same name const dataSources = await this.dsRepository.find({ @@ -360,7 +391,7 @@ export class OrganizationDemoHandler implements ICommandHandler => { - const types = Object.entries(QUERY_RUNNERS).map(([type, QueryRunner]) => { + const types = createDefaultDataSourceTypes(tenant) + return await connection.manager.save(types) +} + +export function createDefaultDataSourceTypes(tenant: ITenant) { + return Object.entries(QUERY_RUNNERS).map(([type, QueryRunner]) => { const queryRunner = new QueryRunner({} as AdapterBaseOptions) const dsType = new DataSourceType() dsType.tenant = tenant @@ -18,6 +23,4 @@ export const createDefaultDataSourceTypes = async ( dsType.configuration = queryRunner.configurationSchema return dsType }) - - return await connection.manager.save(types) } diff --git a/packages/analytics/src/data-source-type/events/handlers/index.ts b/packages/analytics/src/data-source-type/events/handlers/index.ts new file mode 100644 index 000000000..ddf6bcf4f --- /dev/null +++ b/packages/analytics/src/data-source-type/events/handlers/index.ts @@ -0,0 +1,3 @@ +import { TenantCreatedHandler } from "./tenant.created.handler" + +export const EventHandlers = [ TenantCreatedHandler ] \ No newline at end of file diff --git a/packages/analytics/src/data-source-type/events/handlers/tenant.created.handler.ts b/packages/analytics/src/data-source-type/events/handlers/tenant.created.handler.ts new file mode 100644 index 000000000..584a5687d --- /dev/null +++ b/packages/analytics/src/data-source-type/events/handlers/tenant.created.handler.ts @@ -0,0 +1,24 @@ +import { Tenant, TenantCreatedEvent } from '@metad/server-core' +import { Logger } from '@nestjs/common' +import { IEventHandler } from '@nestjs/cqrs' +import { EventsHandler } from '@nestjs/cqrs/dist/decorators/events-handler.decorator' +import { InjectEntityManager } from '@nestjs/typeorm' +import { EntityManager } from 'typeorm' +import { seedDefaultDataSourceTypes } from '../../data-source-type.seed' + +@EventsHandler(TenantCreatedEvent) +export class TenantCreatedHandler implements IEventHandler { + private readonly logger = new Logger(TenantCreatedHandler.name) + + constructor( + @InjectEntityManager() + private entityManager: EntityManager + ) {} + + async handle(event: TenantCreatedEvent) { + this.logger.debug('Tenant Created Event: seed dataSource types') + const { tenantId } = event + const tenant = await this.entityManager.findOne(Tenant, tenantId) + await seedDefaultDataSourceTypes(this.entityManager.connection, tenant) + } +} diff --git a/packages/analytics/src/data-source-type/events/index.ts b/packages/analytics/src/data-source-type/events/index.ts new file mode 100644 index 000000000..99d2fcd91 --- /dev/null +++ b/packages/analytics/src/data-source-type/events/index.ts @@ -0,0 +1 @@ +export * from './handlers/' \ No newline at end of file diff --git a/packages/analytics/src/data-source/data-source.controller.ts b/packages/analytics/src/data-source/data-source.controller.ts index 1cc0db051..986430a78 100644 --- a/packages/analytics/src/data-source/data-source.controller.ts +++ b/packages/analytics/src/data-source/data-source.controller.ts @@ -175,7 +175,11 @@ export class DataSourceController extends CrudController { @Post('/ping') async ping(@Body() body: IDataSource): Promise { - return this.dsService.ping(body) + try { + return await this.dsService.ping(body) + } catch (err) { + throw new HttpException(err.message, HttpStatus.INTERNAL_SERVER_ERROR) + } } @Post('/:id/ping') diff --git a/packages/analytics/src/data-source/data-source.service.ts b/packages/analytics/src/data-source/data-source.service.ts index f49adee2b..199c8aed2 100644 --- a/packages/analytics/src/data-source/data-source.service.ts +++ b/packages/analytics/src/data-source/data-source.service.ts @@ -85,7 +85,7 @@ export class DataSourceService extends TenantOrganizationAwareCrudService('OLAP_HOST') || 'olap' + const olapHost = this.configService.get('OLAP_HOST') || 'localhost' const olapPort = this.configService.get('OLAP_PORT') || '8080' return axios .post(`http://${olapHost}:${olapPort}/xmla`, body, { diff --git a/packages/analytics/src/model/model.service.ts b/packages/analytics/src/model/model.service.ts index fd1374e84..543ca8972 100644 --- a/packages/analytics/src/model/model.service.ts +++ b/packages/analytics/src/model/model.service.ts @@ -263,7 +263,7 @@ export class SemanticModelService extends BusinessAreaAwareCrudService('OLAP_HOST') || 'olap' + const olapHost = this.configService.get('OLAP_HOST') || 'localhost' const olapPort = this.configService.get('OLAP_PORT') || '8080' const headers = { diff --git a/packages/contracts/src/organization.model.ts b/packages/contracts/src/organization.model.ts index b6602b51c..d60439e55 100644 --- a/packages/contracts/src/organization.model.ts +++ b/packages/contracts/src/organization.model.ts @@ -6,6 +6,7 @@ import { IFeatureOrganization } from './feature.model'; import { IOrganizationLanguage } from './organization-language.model'; import { ITag } from './tag-entity.model'; import { ITenant } from './tenant.model'; +import { LanguagesEnum } from './user.model'; export enum OrganizationPermissionsEnum { ALLOW_MANUAL_TIME = 'allowManualTime', @@ -90,6 +91,7 @@ export interface IOrganization extends IBasePerTenantEntityModel { defaultInvoiceEstimateTerms?: string; convertAcceptedEstimates?: boolean; daysUntilDue?: number; + preferredLanguage?: LanguagesEnum; } export interface IOrganizationFindInput extends IBasePerTenantEntityModel { @@ -141,6 +143,7 @@ export interface IOrganizationCreateInput extends IContact { defaultInvoiceEstimateTerms?: string; convertAcceptedEstimates?: boolean; daysUntilDue?: number; + preferredLanguage?: LanguagesEnum; isImporting?: boolean; sourceId?: string; diff --git a/packages/contracts/src/tenant.model.ts b/packages/contracts/src/tenant.model.ts index 384d097f7..7560c6130 100644 --- a/packages/contracts/src/tenant.model.ts +++ b/packages/contracts/src/tenant.model.ts @@ -4,8 +4,9 @@ import { FileStorageProviderEnum, S3FileStorageProviderConfig } from './file-provider'; -import { IOrganization } from './organization.model'; +import { IOrganization, IOrganizationCreateInput } from './organization.model'; import { IRolePermission } from './role-permission.model'; +import { IUserCreateInput } from './user.model'; export interface ITenant { id?: string; @@ -26,8 +27,13 @@ export interface ITenantCreateInput { isImporting?: boolean; sourceId?: string; userSourceId?: string; + + superAdmin?: IUserCreateInput + defaultOrganization?: IOrganizationCreateInput } export interface ITenantSetting extends S3FileStorageProviderConfig { fileStorageProvider?: FileStorageProviderEnum; } + +export const DEFAULT_TENANT = 'Default Tenant'; \ No newline at end of file diff --git a/packages/contracts/src/user.model.ts b/packages/contracts/src/user.model.ts index 9550ad6a1..bd11dda12 100644 --- a/packages/contracts/src/user.model.ts +++ b/packages/contracts/src/user.model.ts @@ -108,17 +108,15 @@ export interface IUserPasswordInput { } export enum LanguagesEnum { - CHINESE = "zh", - ENGLISH = 'en', - BULGARIAN = 'bg', - HEBREW = 'he', - RUSSIAN = 'ru' + Chinese = "zh", + TraditionalChinese = 'zh-Hant', + English = 'en', } export const LanguagesMap = { - 'zh-CN': LanguagesEnum.CHINESE, - 'zh-Hans': LanguagesEnum.CHINESE, - 'zh': LanguagesEnum.CHINESE, + 'zh-CN': LanguagesEnum.Chinese, + 'zh-Hans': LanguagesEnum.Chinese, + 'zh': LanguagesEnum.Chinese, } export enum ComponentLayoutStyleEnum { diff --git a/packages/olap/package.json b/packages/olap/package.json index 6ec5aae91..27ac1dad7 100644 --- a/packages/olap/package.json +++ b/packages/olap/package.json @@ -5,7 +5,7 @@ "build": "rimraf target && mvn install && yarn run docker-build", "mvn": "mvn install", "docker-build": "docker build --tag pangolin/olap .", - "start": "mvn spring-boot:run" + "start": "sh ./run.sh" }, "devDependencies": { "rimraf": "^3.0.2" diff --git a/packages/olap/run.sh b/packages/olap/run.sh new file mode 100644 index 000000000..e88cacdb0 --- /dev/null +++ b/packages/olap/run.sh @@ -0,0 +1,9 @@ +eval "$( + cat ../../.env | awk '!/^\s*#/' | awk '!/^\s*$/' | while IFS='' read -r line; do + key=$(echo "$line" | cut -d '=' -f 1) + value=$(echo "$line" | cut -d '=' -f 2-) + echo "export $key=\"$value\"" + done +)" + +mvn spring-boot:run diff --git a/packages/olap/src/main/resources/application.properties b/packages/olap/src/main/resources/application.properties index 6a977104e..391bc10ed 100644 --- a/packages/olap/src/main/resources/application.properties +++ b/packages/olap/src/main/resources/application.properties @@ -7,10 +7,10 @@ samlTokenStrategyBeanName=defaultSamlTokenStrategy # when you deploy the controller with your own data sources removeDemoConnections=false -redis.database=${OLAP_REDIS_DATABASE:0} -redis.host=${OLAP_REDIS_HOST:localhost} -redis.port=${OLAP_REDIS_PORT:6379} -redis.password=${OLAP_REDIS_PASSWORD:nH7pR3^mHf%DN3RR26Ew} +redis.database=${REDIS_DATABASE:0} +redis.host=${REDIS_HOST:localhost} +redis.port=${REDIS_PORT:6379} +redis.password=${REDIS_PASSWORD:} redis.timeout=60000 # logging.level.root=DEBUG \ No newline at end of file diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 677455653..26af4be2e 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -381,7 +381,7 @@ export class AuthService extends SocialAuthService { }, originalUrl: 'oauth' }, - LanguagesEnum.CHINESE + LanguagesEnum.Chinese ) ) const { token, refreshToken } = await this.createToken(user) @@ -424,7 +424,7 @@ export class AuthService extends SocialAuthService { const user = await this.commandBus.execute( new AuthTrialCommand( { user: { email: emails[0].value }, originalUrl: 'oauth' }, - LanguagesEnum.CHINESE + LanguagesEnum.Chinese ) ) const { token } = await this.createToken(user) diff --git a/packages/server/src/auth/commands/handlers/auth.trial.handler.ts b/packages/server/src/auth/commands/handlers/auth.trial.handler.ts index 92343380e..0ab2f5214 100644 --- a/packages/server/src/auth/commands/handlers/auth.trial.handler.ts +++ b/packages/server/src/auth/commands/handlers/auth.trial.handler.ts @@ -1,10 +1,10 @@ -import { CurrenciesEnum, DefaultValueDateTypeEnum, IOrganizationCreateInput, IUser, RolesEnum } from '@metad/contracts' +import { CurrenciesEnum, DEFAULT_TENANT, DefaultValueDateTypeEnum, IOrganizationCreateInput, IUser, RolesEnum } from '@metad/contracts' import { ConflictException, Logger } from '@nestjs/common' import { CommandBus, CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs' import { EmployeeCreateCommand } from '../../../employee/index' import { OrganizationCreateCommand } from '../../../organization/commands' import { RoleService } from '../../../role/role.service' -import { DEFAULT_TENANT, TenantService } from '../../../tenant/index' +import { TenantService } from '../../../tenant/index' import { UserService } from '../../../user' import { AuthService } from '../../auth.service' import { AuthTrialCommand } from '../auth.trial.command' diff --git a/packages/server/src/core/seeds/seed-data.service.ts b/packages/server/src/core/seeds/seed-data.service.ts index 9a67526d1..e4f33aff2 100644 --- a/packages/server/src/core/seeds/seed-data.service.ts +++ b/packages/server/src/core/seeds/seed-data.service.ts @@ -17,7 +17,8 @@ import { IOrganization, IRole, ITenant, - IUser + IUser, + DEFAULT_TENANT } from '@metad/contracts'; import { createRoles } from '../../role/role.seed'; import { @@ -33,7 +34,6 @@ import { } from '../../employee/employee.seed'; import { createDefaultOrganizations, - createRandomOrganizations, DEFAULT_ORGANIZATIONS, getTenantDefaultOrganization, OrganizationDemoCommand @@ -47,7 +47,6 @@ import { cleanUpRolePermissions, createRolePermissions } from '../../role-permis import { createDefaultTenant, createRandomTenants, - DEFAULT_TENANT, TenantCreatedEvent, TenantService } from '../../tenant'; diff --git a/packages/server/src/email-template/queries/handlers/email-template.find.handler.ts b/packages/server/src/email-template/queries/handlers/email-template.find.handler.ts index e8893ece3..77b2abc39 100644 --- a/packages/server/src/email-template/queries/handlers/email-template.find.handler.ts +++ b/packages/server/src/email-template/queries/handlers/email-template.find.handler.ts @@ -79,7 +79,7 @@ export class FindEmailTemplateHandler } else { try { const { hbs, mjml } = await this.emailTemplateService.findOne({ - languageCode: LanguagesEnum.ENGLISH, + languageCode: LanguagesEnum.English, name: `${name}/${type}`, organizationId, tenantId @@ -88,7 +88,7 @@ export class FindEmailTemplateHandler template = mjml; } catch (error) { const { hbs, mjml } = await this.emailTemplateService.findOne({ - languageCode: LanguagesEnum.ENGLISH, + languageCode: LanguagesEnum.English, name: `${name}/${type}`, organizationId: IsNull(), tenantId diff --git a/packages/server/src/email/email.service.ts b/packages/server/src/email/email.service.ts index 47ba1ca00..0253f9f27 100644 --- a/packages/server/src/email/email.service.ts +++ b/packages/server/src/email/email.service.ts @@ -148,7 +148,7 @@ export class EmailService extends TenantAwareCrudService { // Find email template for customized for given organization let emailTemplate: IEmailTemplate = await this.emailTemplateRepository.findOne({ name: view, - languageCode: locals.locale || LanguagesEnum.ENGLISH, + languageCode: locals.locale || LanguagesEnum.English, organizationId: locals.organizationId, tenantId: locals.tenantId }); @@ -157,7 +157,7 @@ export class EmailService extends TenantAwareCrudService { if (!emailTemplate) { emailTemplate = await this.emailTemplateRepository.findOne({ name: view, - languageCode: locals.locale || LanguagesEnum.ENGLISH, + languageCode: locals.locale || LanguagesEnum.English, organizationId: IsNull(), tenantId: locals.tenantId }); diff --git a/packages/server/src/employee/commands/handlers/employee.create.handler.ts b/packages/server/src/employee/commands/handlers/employee.create.handler.ts index 5be83f4c5..ae0c79f58 100644 --- a/packages/server/src/employee/commands/handlers/employee.create.handler.ts +++ b/packages/server/src/employee/commands/handlers/employee.create.handler.ts @@ -22,7 +22,7 @@ export class EmployeeCreateHandler public async execute(command: EmployeeCreateCommand): Promise { const { input } = command; - const languageCode = command.languageCode || LanguagesEnum.ENGLISH; + const languageCode = command.languageCode || LanguagesEnum.English; const inputWithHash = await this._addHashAndLanguage( input, languageCode diff --git a/packages/server/src/employee/default-employees.ts b/packages/server/src/employee/default-employees.ts index 282b415a7..451647baf 100644 --- a/packages/server/src/employee/default-employees.ts +++ b/packages/server/src/employee/default-employees.ts @@ -9,7 +9,7 @@ export const DEFAULT_EMPLOYEES: any = [ imageUrl: 'assets/images/avatar-default.svg', startedWorkOn: '2018-03-20', employeeLevel: 'A', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE } ]; @@ -22,7 +22,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ lastName: 'Konviser', imageUrl: 'assets/images/avatars/ruslan.jpg', employeeLevel: 'A', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -34,7 +34,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2018-03-20', endWork: null, employeeLevel: 'D', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -46,7 +46,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2018-03-19', endWork: null, employeeLevel: 'C', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -58,7 +58,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2018-05-25', endWork: null, employeeLevel: 'C', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -70,7 +70,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2019-06-17', endWork: null, employeeLevel: 'B', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -82,7 +82,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2019-08-01', endWork: null, employeeLevel: 'B', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -94,7 +94,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2019-11-27', endWork: null, employeeLevel: null, - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -106,7 +106,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2019-11-26', endWork: null, employeeLevel: null, - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -118,7 +118,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2020-03-16', endWork: null, employeeLevel: 'A', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -130,7 +130,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2020-02-05', endWork: null, employeeLevel: 'A', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -142,7 +142,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2020-03-02', endWork: null, employeeLevel: 'A', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -154,7 +154,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2019-11-27', endWork: null, employeeLevel: null, - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -166,7 +166,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2020-03-07', endWork: null, employeeLevel: null, - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -178,7 +178,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2020-03-07', endWork: null, employeeLevel: null, - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -190,7 +190,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2018-08-01', endWork: null, employeeLevel: 'C', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, { @@ -202,7 +202,7 @@ export const DEFAULT_PEANUT_EMPLOYEES: any = [ startedWorkOn: '2018-08-01', endWork: null, employeeLevel: 'C', - preferredLanguage: LanguagesEnum.ENGLISH, + preferredLanguage: LanguagesEnum.English, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE } ]; \ No newline at end of file diff --git a/packages/server/src/feature/commands/feature-bulk-create.command.ts b/packages/server/src/feature/commands/feature-bulk-create.command.ts new file mode 100644 index 000000000..3464a2709 --- /dev/null +++ b/packages/server/src/feature/commands/feature-bulk-create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { ITenant } from '@metad/contracts'; + +export class FeatureBulkCreateCommand implements ICommand { + static readonly type = '[Feature] Bulk Create'; + + constructor(public readonly tenants?: ITenant[]) {} +} diff --git a/packages/server/src/feature/commands/handlers/feature-bulk-create.handler.ts b/packages/server/src/feature/commands/handlers/feature-bulk-create.handler.ts new file mode 100644 index 000000000..352b04427 --- /dev/null +++ b/packages/server/src/feature/commands/handlers/feature-bulk-create.handler.ts @@ -0,0 +1,90 @@ +import { IFeature, IFeatureCreateInput, ITenant } from '@metad/contracts' +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' +import { DEFAULT_FEATURES } from '../../default-features' +import { FeatureOrganization } from '../../feature-organization.entity' +import { Feature } from '../../feature.entity' +import { createFeature } from '../../feature.seed' +import { FeatureBulkCreateCommand } from '../feature-bulk-create.command' + +@CommandHandler(FeatureBulkCreateCommand) +export class FeatureBulkCreateHandler implements ICommandHandler { + constructor( + @InjectRepository(FeatureOrganization) + public readonly featureOrganizationRepository: Repository, + + @InjectRepository(Feature) + public readonly featureRepository: Repository + ) {} + + public async execute(command: FeatureBulkCreateCommand): Promise { + const { tenants } = command + + // Create default features + DEFAULT_FEATURES.forEach(async (item: IFeatureCreateInput) => { + let feature: IFeature = await this.featureRepository.findOne({ + where: { code: item.code }, + }) + // If feature not exist, create it + if (!feature) { + feature = createFeature(item) + feature = await this.featureRepository.save(feature) + } + + const { children = [] } = item + if (children.length > 0) { + const featureChildren: IFeature[] = [] + for await(const child of children) { + const childFeature: IFeature = await this.featureRepository.findOne({ + where: { code: child.code }, + }) + + // If child feature not exist, create it + if (!childFeature) { + const childFeature: IFeature = createFeature(child) + childFeature.parent = feature + featureChildren.push(childFeature) + } + } + + await this.featureRepository.save(featureChildren) + } + }) + + // // Create feature toggle for every new tenant + // tenants.forEach((tenant: ITenant) => { + // DEFAULT_FEATURES.forEach(async (item: IFeatureCreateInput) => { + // const feature: IFeature = await this.featureRepository.findOne({ + // where: { code: item.code }, + // relations: ['featureOrganizations'] + // }) + + // await this.featureOrganizationRepository.save( + // new FeatureOrganization({ + // isEnabled: feature.isEnabled, + // tenant, + // featureId: feature.id + // }) + // ) + + // const { children = [] } = item + // children?.forEach(async (child: IFeature) => { + // const feature: IFeature = await this.featureRepository.findOne({ + // where: { code: child.code }, + // relations: ['featureOrganizations'] + // }) + // const childFeatureToggle: FeatureOrganization = new FeatureOrganization({ + // isEnabled: feature.isEnabled, + // tenant, + // featureId: feature.id + // }) + + // await this.featureOrganizationRepository.save(childFeatureToggle) + // }) + // }) + // }) + + return + } +} diff --git a/packages/server/src/feature/commands/handlers/index.ts b/packages/server/src/feature/commands/handlers/index.ts index c2a2becec..c618e2c5d 100644 --- a/packages/server/src/feature/commands/handlers/index.ts +++ b/packages/server/src/feature/commands/handlers/index.ts @@ -1,3 +1,4 @@ +import { FeatureBulkCreateHandler } from './feature-bulk-create.handler'; import { FeatureToggleUpdateHandler } from './feature-toggle.update.handler'; -export const CommandHandlers = [FeatureToggleUpdateHandler]; +export const CommandHandlers = [FeatureToggleUpdateHandler, FeatureBulkCreateHandler]; diff --git a/packages/server/src/feature/commands/index.ts b/packages/server/src/feature/commands/index.ts index 540065714..0797bfba8 100644 --- a/packages/server/src/feature/commands/index.ts +++ b/packages/server/src/feature/commands/index.ts @@ -1 +1,2 @@ -export * from './feature-toggle.update.command'; \ No newline at end of file +export * from './feature-toggle.update.command'; +export * from './feature-bulk-create.command'; \ No newline at end of file diff --git a/packages/server/src/feature/feature.service.ts b/packages/server/src/feature/feature.service.ts index 8726786aa..9522e6a00 100644 --- a/packages/server/src/feature/feature.service.ts +++ b/packages/server/src/feature/feature.service.ts @@ -11,7 +11,6 @@ import { import * as chalk from 'chalk'; import { TenantService } from '../tenant/tenant.service'; import { DEFAULT_FEATURES } from './default-features'; -import { DEFAULT_TENANT } from '../tenant/default-tenants'; import { createFeature } from './feature.seed'; @Injectable() diff --git a/packages/server/src/organization/commands/organization.demo.command.ts b/packages/server/src/organization/commands/organization.demo.command.ts index 64f001a5c..4f7e6f284 100644 --- a/packages/server/src/organization/commands/organization.demo.command.ts +++ b/packages/server/src/organization/commands/organization.demo.command.ts @@ -3,5 +3,5 @@ import { ICommand } from '@nestjs/cqrs'; export class OrganizationDemoCommand implements ICommand { static readonly type = '[Organization] Demo' - constructor(public readonly input: { id: string }) {} + constructor(public readonly input: { id: string; options?: any }) {} } diff --git a/packages/server/src/organization/organization.controller.ts b/packages/server/src/organization/organization.controller.ts index 29ea4d7d1..3f79c40f3 100644 --- a/packages/server/src/organization/organization.controller.ts +++ b/packages/server/src/organization/organization.controller.ts @@ -138,11 +138,11 @@ export class OrganizationController extends CrudController { }) @HttpCode(HttpStatus.OK) @UseGuards(RoleGuard, TenantPermissionGuard) - @Roles(RolesEnum.ADMIN, RolesEnum.TRIAL) + @Roles(RolesEnum.SUPER_ADMIN, RolesEnum.ADMIN, RolesEnum.TRIAL) @Post(':id/demo') - async generateDemo(@Param('id', UUIDValidationPipe) id: string) { + async generateDemo(@Param('id', UUIDValidationPipe) id: string, @Body() body: any) { try { - return await this.organizationService.generateDemo(id) + return await this.organizationService.generateDemo(id, body) } catch(err) { throw new InternalServerErrorException(err.message) } diff --git a/packages/server/src/organization/organization.entity.ts b/packages/server/src/organization/organization.entity.ts index c62799f3d..0a3a403c5 100644 --- a/packages/server/src/organization/organization.entity.ts +++ b/packages/server/src/organization/organization.entity.ts @@ -33,6 +33,7 @@ import { IEmployee, IOrganizationLanguage, IFeatureOrganization, + LanguagesEnum } from '@metad/contracts'; import { Contact, @@ -383,6 +384,12 @@ export class Organization extends TenantBaseEntity implements IOrganization { @IsOptional() @Column({ nullable: true }) daysUntilDue?: number; + + @ApiProperty({ type: () => String, enum: LanguagesEnum }) + @IsEnum(LanguagesEnum) + @Column({ nullable: true }) + preferredLanguage?: LanguagesEnum + /* |-------------------------------------------------------------------------- | @ManyToOne diff --git a/packages/server/src/organization/organization.seed.ts b/packages/server/src/organization/organization.seed.ts index 90dd77b39..96ca8c605 100644 --- a/packages/server/src/organization/organization.seed.ts +++ b/packages/server/src/organization/organization.seed.ts @@ -3,12 +3,10 @@ import * as moment from 'moment'; import * as timezone from 'moment-timezone'; import { Connection } from 'typeorm'; import * as faker from 'faker'; -import { getDummyImage } from '../core'; import { Organization, } from '../core/entities/internal'; import { - DefaultValueDateTypeEnum, BonusTypeEnum, WeekDaysEnum, AlignmentOptions, @@ -17,7 +15,6 @@ import { ITenant, DEFAULT_DATE_FORMATS } from '@metad/contracts'; -import { environment as env } from '@metad/server-config'; export const getDefaultOrganization = async ( connection: Connection, @@ -131,160 +128,6 @@ export const createDefaultOrganizations = async ( return defaultOrganizationsInserted; }; -export const createRandomOrganizations = async ( - connection: Connection, - tenants: ITenant[], - noOfOrganizations: number -): Promise> => { - const defaultDateTypes = Object.values(DefaultValueDateTypeEnum); - // const skills = await getSkills(connection); - // const contacts = await getContacts(connection); - const tenantOrganizations: Map = new Map(); - let allOrganizations: IOrganization[] = []; - - tenants.forEach((tenant) => { - const randomOrganizations: IOrganization[] = []; - if (tenant.name === 'Ever') { - tenantOrganizations.set(tenant, defaultOrganizationsInserted); - } else { - for (let index = 0; index < noOfOrganizations; index++) { - // const organizationSkills = _.chain(skills) - // .shuffle() - // .take(faker.datatype.number({ min: 1, max: 4 })) - // .values() - // .value(); - const organization: IOrganization = new Organization(); - const companyName = faker.company.companyName(); - - const logoAbbreviation = _extractLogoAbbreviation(companyName); - - organization.name = companyName; - organization.isDefault = (index === 0) || false; - organization.profile_link = generateLink(companyName); - organization.currency = env.defaultCurrency; - organization.defaultValueDateType = - defaultDateTypes[index % defaultDateTypes.length]; - organization.imageUrl = getDummyImage( - 330, - 300, - logoAbbreviation - ); - organization.invitesAllowed = true; - organization.overview = faker.name.jobDescriptor(); - organization.short_description = faker.name.jobDescriptor(); - organization.client_focus = faker.name.jobDescriptor(); - organization.show_profits = false; - organization.show_bonuses_paid = false; - organization.show_income = false; - organization.show_total_hours = false; - organization.show_projects_count = true; - organization.show_minimum_project_size = true; - organization.show_clients_count = true; - organization.show_employees_count = true; - organization.banner = faker.name.jobDescriptor(); - - const { bonusType, bonusPercentage } = randomBonus(); - organization.bonusType = bonusType; - organization.bonusPercentage = bonusPercentage; - organization.registrationDate = faker.date.past( - Math.floor(Math.random() * 10) + 1 - ); - - // organization.skills = organizationSkills; - organization.brandColor = faker.random.arrayElement([ - 'red', - 'green', - 'blue', - 'orange', - 'yellow' - ]); - // organization.contact = faker.random.arrayElement(contacts); - organization.timeZone = faker.random.arrayElement( - timezone.tz.names().filter((zone) => zone.includes('/')) - ); - organization.dateFormat = faker.random.arrayElement(DEFAULT_DATE_FORMATS); - organization.defaultAlignmentType = faker.random.arrayElement( - Object.keys(AlignmentOptions) - ); - organization.fiscalStartDate = moment(new Date()) - .add(faker.datatype.number(10), 'days') - .toDate(); - organization.fiscalEndDate = moment( - organization.fiscalStartDate - ) - .add(faker.datatype.number(10), 'days') - .toDate(); - organization.futureDateAllowed = faker.datatype.boolean(); - organization.inviteExpiryPeriod = faker.datatype.number(50); - organization.numberFormat = faker.random.arrayElement([ - 'USD', - 'BGN', - 'ILS' - ]); - organization.officialName = faker.company.companyName(); - organization.separateInvoiceItemTaxAndDiscount = faker.datatype.boolean(); - organization.startWeekOn = WeekDaysEnum.MONDAY; - organization.totalEmployees = faker.datatype.number(4); - organization.tenant = tenant; - organization.valueDate = moment(new Date()) - .add(faker.datatype.number(10), 'days') - .toDate(); - - randomOrganizations.push(organization); - } - - tenantOrganizations.set(tenant, randomOrganizations); - } - - allOrganizations = allOrganizations.concat(randomOrganizations); - }); - - await insertOrganizations(connection, allOrganizations); - return tenantOrganizations; -}; - -const insertOrganizations = async ( - connection: Connection, - organizations: IOrganization[] -): Promise => { - await connection.manager.save(organizations); -}; - -const _extractLogoAbbreviation = (companyName: string) => { - const logoFirstWordFirstLetterIndex = 0; - const companyNameLastEmptyLetterIndex = companyName.lastIndexOf(' '); - const logoFirstLetter = companyName[logoFirstWordFirstLetterIndex]; - - let logoAbbreviation = logoFirstLetter; - - if ( - companyNameLastEmptyLetterIndex !== -1 && - companyNameLastEmptyLetterIndex !== logoFirstWordFirstLetterIndex - ) { - const logoLastWordFirstLetterIndex = - companyNameLastEmptyLetterIndex + 1; - const logoSecondLetter = companyName[logoLastWordFirstLetterIndex]; - - logoAbbreviation += logoSecondLetter; - } - - return logoAbbreviation; -}; - -const randomBonus = () => { - const randomNumberBetween = (min, max) => - Math.floor(Math.random() * (max - min + 1) + min); - - const bonusType = Object.values(BonusTypeEnum)[randomNumberBetween(0, 1)]; - - const bonusPercentage = - bonusType === BonusTypeEnum.PROFIT_BASED_BONUS - ? randomNumberBetween(65, 75) - : randomNumberBetween(5, 10); - - return { bonusType, bonusPercentage }; -}; - const generateLink = (name) => { return name.replace(/[^A-Z0-9]+/gi, '-').toLowerCase(); }; diff --git a/packages/server/src/organization/organization.service.ts b/packages/server/src/organization/organization.service.ts index d7ec080ad..7c054415f 100644 --- a/packages/server/src/organization/organization.service.ts +++ b/packages/server/src/organization/organization.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { CommandBus } from '@nestjs/cqrs'; import { Repository, FindOneOptions } from 'typeorm'; import { TenantAwareCrudService } from './../core/crud'; import { Organization } from './organization.entity'; -import { CommandBus } from '@nestjs/cqrs'; import { OrganizationDemoCommand } from './commands'; @Injectable() @@ -38,12 +38,13 @@ export class OrganizationService extends TenantAwareCrudService { ); } - public async generateDemo(id: string) { + public async generateDemo(id: string, options: any) { const organization = await this.organizationRepository.findOne(id); await this.commandBus.execute( new OrganizationDemoCommand({ - id + id, + options }) ) diff --git a/packages/server/src/role-permission/commands/handlers/index.ts b/packages/server/src/role-permission/commands/handlers/index.ts new file mode 100644 index 000000000..c53cdaafb --- /dev/null +++ b/packages/server/src/role-permission/commands/handlers/index.ts @@ -0,0 +1,3 @@ +import { TenantRolePermissionBulkCreateHandler } from './tenant-role-bulk-create.handler'; + +export const CommandHandlers = [TenantRolePermissionBulkCreateHandler]; diff --git a/packages/server/src/role-permission/commands/handlers/tenant-role-bulk-create.handler.ts b/packages/server/src/role-permission/commands/handlers/tenant-role-bulk-create.handler.ts new file mode 100644 index 000000000..103672c2b --- /dev/null +++ b/packages/server/src/role-permission/commands/handlers/tenant-role-bulk-create.handler.ts @@ -0,0 +1,16 @@ +import { IRolePermission } from '@metad/contracts' +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { RolePermissionService } from '../../../role-permission/role-permission.service' +import { TenantRolePermissionBulkCreateCommand } from '../tenant-role-bulk-create.command' + +@CommandHandler(TenantRolePermissionBulkCreateCommand) +export class TenantRolePermissionBulkCreateHandler implements ICommandHandler { + constructor(private readonly rolePermissionService: RolePermissionService) {} + + public async execute(command: TenantRolePermissionBulkCreateCommand): Promise { + const { input: tenants } = command + + //create roles-permissions after create tenant + return await this.rolePermissionService.updateRolesAndPermissions(tenants) + } +} diff --git a/packages/server/src/role-permission/commands/index.ts b/packages/server/src/role-permission/commands/index.ts new file mode 100644 index 000000000..79a03150c --- /dev/null +++ b/packages/server/src/role-permission/commands/index.ts @@ -0,0 +1 @@ +export * from './tenant-role-bulk-create.command'; \ No newline at end of file diff --git a/packages/server/src/role-permission/commands/tenant-role-bulk-create.command.ts b/packages/server/src/role-permission/commands/tenant-role-bulk-create.command.ts new file mode 100644 index 000000000..c3e481308 --- /dev/null +++ b/packages/server/src/role-permission/commands/tenant-role-bulk-create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { ITenant } from '@metad/contracts'; + +export class TenantRolePermissionBulkCreateCommand implements ICommand { + static readonly type = '[RolePermission] Bulk Create'; + + constructor(public readonly input: ITenant[]) {} +} diff --git a/packages/server/src/role-permission/index.ts b/packages/server/src/role-permission/index.ts index 1b9da1ad7..031ef26c4 100644 --- a/packages/server/src/role-permission/index.ts +++ b/packages/server/src/role-permission/index.ts @@ -2,3 +2,4 @@ export * from './default-role-permissions' export * from './role-permission.module' export * from './role-permission.seed' export * from './role-permission.service' +export * from './commands/index' \ No newline at end of file diff --git a/packages/server/src/role-permission/role-permission.module.ts b/packages/server/src/role-permission/role-permission.module.ts index 6e5eb1878..29b810e72 100644 --- a/packages/server/src/role-permission/role-permission.module.ts +++ b/packages/server/src/role-permission/role-permission.module.ts @@ -7,6 +7,8 @@ import { RolePermission } from './role-permission.entity'; import { RolePermissionService } from './role-permission.service'; import { UserModule } from '../user/user.module'; import { TenantModule } from '../tenant/tenant.module'; +import { CommandHandlers } from './commands/handlers'; +import { RoleModule } from '../role/role.module'; @Module({ imports: [ @@ -16,10 +18,11 @@ import { TenantModule } from '../tenant/tenant.module'; forwardRef(() => TypeOrmModule.forFeature([ RolePermission ])), forwardRef(() => TenantModule), forwardRef(() => UserModule), + forwardRef(() => RoleModule), CqrsModule ], controllers: [RolePermissionController], - providers: [RolePermissionService], + providers: [RolePermissionService, ...CommandHandlers], exports: [TypeOrmModule, RolePermissionService] }) export class RolePermissionModule {} diff --git a/packages/server/src/role-permission/role-permission.service.ts b/packages/server/src/role-permission/role-permission.service.ts index 93102b889..a9202702b 100644 --- a/packages/server/src/role-permission/role-permission.service.ts +++ b/packages/server/src/role-permission/role-permission.service.ts @@ -9,21 +9,24 @@ import { IRole, IRolePermission, IImportRecord, - IRolePermissionMigrateInput + IRolePermissionMigrateInput, + PermissionsEnum } from '@metad/contracts'; +import { environment } from '@metad/server-config'; import { TenantAwareCrudService } from './../core/crud'; import { RequestContext } from './../core/context'; import { ImportRecordUpdateOrCreateCommand } from './../export-import/import-record'; import { RolePermission } from './role-permission.entity'; import { Role } from '../role/role.entity'; import { DEFAULT_ROLE_PERMISSIONS } from './default-role-permissions'; +import { RoleService } from '../role/role.service'; @Injectable() export class RolePermissionService extends TenantAwareCrudService { constructor( @InjectRepository(RolePermission) private readonly rolePermissionRepository: Repository, - + private readonly roleService: RoleService, private readonly _commandBus: CommandBus ) { super(rolePermissionRepository); @@ -82,36 +85,44 @@ export class RolePermissionService extends TenantAwareCrudService { if (!tenants.length) { return; } - + // removed permissions for all users in DEMO mode + const deniedPermissions = [ + PermissionsEnum.ACCESS_DELETE_ACCOUNT, + PermissionsEnum.ACCESS_DELETE_ALL_DATA + ]; const rolesPermissions: IRolePermission[] = []; for await (const tenant of tenants) { - for await (const role of roles) { + const roles = (await this.roleService.findAll({ + where: { + tenantId: tenant.id + } + })).items; + for (const role of roles) { const defaultPermissions = DEFAULT_ROLE_PERMISSIONS.find( (defaultRole) => role.name === defaultRole.role ); - if ( - defaultPermissions && - defaultPermissions['defaultEnabledPermissions'] - ) { - const { defaultEnabledPermissions } = defaultPermissions; - for await (const permission of defaultEnabledPermissions) { + + if (defaultPermissions) { + const { defaultEnabledPermissions = [] } = defaultPermissions; + for (const permission of defaultEnabledPermissions) { + if (environment.demo ? deniedPermissions.includes(permission) : false) { + continue + } const rolePermission = new RolePermission(); rolePermission.roleId = role.id; rolePermission.permission = permission; - rolePermission.enabled = true; + rolePermission.enabled = defaultEnabledPermissions.includes(permission); rolePermission.tenant = tenant; rolesPermissions.push(rolePermission); } } } } - await this.rolePermissionRepository.save(rolesPermissions); return rolesPermissions; } diff --git a/packages/server/src/role/commands/handlers/tenant-role-bulk-create.handler.ts b/packages/server/src/role/commands/handlers/tenant-role-bulk-create.handler.ts index 743b66aca..4e2044063 100644 --- a/packages/server/src/role/commands/handlers/tenant-role-bulk-create.handler.ts +++ b/packages/server/src/role/commands/handlers/tenant-role-bulk-create.handler.ts @@ -1,15 +1,16 @@ import { IRole } from '@metad/contracts'; -import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -// import { RolePermissionService } from '../../../role-permission/role-permission.service'; +import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { RoleService } from '../../role.service'; import { TenantRoleBulkCreateCommand } from '../tenant-role-bulk-create.command'; +import { TenantRolePermissionBulkCreateCommand } from '../../../role-permission'; + @CommandHandler(TenantRoleBulkCreateCommand) export class TenantRoleBulkCreateHandler implements ICommandHandler { constructor( private readonly roleService: RoleService, - // private readonly rolePermissionService: RolePermissionService + private readonly commandBus: CommandBus ) {} public async execute( @@ -19,10 +20,7 @@ export class TenantRoleBulkCreateHandler //create roles/permissions after create tenant const roles = await this.roleService.createBulk(tenants); - // await this.rolePermissionService.updateRolesAndPermissions( - // tenants, - // roles - // ); + await this.commandBus.execute(new TenantRolePermissionBulkCreateCommand(tenants)) return roles; } } diff --git a/packages/server/src/server.module.ts b/packages/server/src/server.module.ts index 6b9b8b5e0..fb32f1bd6 100644 --- a/packages/server/src/server.module.ts +++ b/packages/server/src/server.module.ts @@ -58,7 +58,7 @@ import { StorageFileModule } from './storage-file/storage-file.module' }, ]), I18nModule.forRoot({ - fallbackLanguage: LanguagesEnum.ENGLISH, + fallbackLanguage: LanguagesEnum.English, parser: I18nJsonParser, parserOptions: { path: path.resolve(__dirname, 'i18n/'), diff --git a/packages/server/src/shared/decorators/language.decorator.ts b/packages/server/src/shared/decorators/language.decorator.ts index cc5873cc9..7aeca27d8 100644 --- a/packages/server/src/shared/decorators/language.decorator.ts +++ b/packages/server/src/shared/decorators/language.decorator.ts @@ -5,6 +5,6 @@ export const LanguageDecorator = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const headers = request.headers; - return (LanguagesMap[headers['language']] ?? headers['language']) || LanguagesEnum.ENGLISH; + return (LanguagesMap[headers['language']] ?? headers['language']) || LanguagesEnum.English; } ); \ No newline at end of file diff --git a/packages/server/src/tenant/default-tenants.ts b/packages/server/src/tenant/default-tenants.ts deleted file mode 100644 index e361eb94b..000000000 --- a/packages/server/src/tenant/default-tenants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_TENANT = 'Default Tenant'; \ No newline at end of file diff --git a/packages/server/src/tenant/index.ts b/packages/server/src/tenant/index.ts index 1a439a260..09ad935c3 100644 --- a/packages/server/src/tenant/index.ts +++ b/packages/server/src/tenant/index.ts @@ -1,4 +1,3 @@ -export * from './default-tenants' export * from './events/index' export * from './tenant-setting' export * from './tenant.controller' diff --git a/packages/server/src/tenant/tenant.controller.ts b/packages/server/src/tenant/tenant.controller.ts index 2898db1d9..06188e2e9 100644 --- a/packages/server/src/tenant/tenant.controller.ts +++ b/packages/server/src/tenant/tenant.controller.ts @@ -1,4 +1,4 @@ -import { IPagination, ITenant, ITenantCreateInput, RolesEnum } from '@metad/contracts'; +import { DEFAULT_TENANT, IPagination, ITenant, ITenantCreateInput, RolesEnum } from '@metad/contracts'; import { BadRequestException, Body, @@ -14,19 +14,22 @@ import { Put, UseGuards } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UUIDValidationPipe } from './../shared/pipes'; import { RequestContext } from '../core/context'; import { CrudController } from './../core/crud'; -import { Roles } from './../shared/decorators'; +import { Public, Roles } from './../shared/decorators'; import { RoleGuard, TenantPermissionGuard } from './../shared/guards'; import { Tenant } from './tenant.entity'; import { TenantService } from './tenant.service'; +import { UserCreateCommand } from '../user/commands'; +import { FeatureBulkCreateCommand } from '../feature/commands'; @ApiTags('Tenant') @Controller() export class TenantController extends CrudController { - constructor(private readonly tenantService: TenantService) { + constructor(private readonly tenantService: TenantService, private readonly commandBus: CommandBus) { super(tenantService); } @@ -64,6 +67,17 @@ export class TenantController extends CrudController { }); } + @Public() + @HttpCode(HttpStatus.OK) + @Get('onboard') + async getOnboardDefault(): Promise { + const defaultTenant = await this.tenantService.findOneOrFail({ + name: DEFAULT_TENANT + + }) + return defaultTenant.success + } + @ApiOperation({ summary: 'Find by id' }) @ApiResponse({ status: HttpStatus.OK, @@ -109,6 +123,32 @@ export class TenantController extends CrudController { return await this.tenantService.onboardTenant(entity, user); } + @ApiOperation({ + summary: + 'Create new tenant. The user who creates the tenant is given the super admin role.' + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + 'Invalid input, The response body may contain clues as to what went wrong' + }) + @Public() + @HttpCode(HttpStatus.CREATED) + @Post('onboard') + async onboardDefault(@Body() entity: ITenantCreateInput): Promise { + const defaultTenant = await this.tenantService.findOneOrFail({name: entity.name}) + if (defaultTenant.success) { + throw new BadRequestException('Tenant already exists'); + } + await this.commandBus.execute(new FeatureBulkCreateCommand()) + const user = await this.commandBus.execute(new UserCreateCommand(entity.superAdmin)) + return await this.tenantService.onboardTenant(entity, user); + } + @ApiOperation({ summary: 'Update existin tenant. The user who updates the tenant is given the super admin role.' diff --git a/packages/server/src/tenant/tenant.seed.ts b/packages/server/src/tenant/tenant.seed.ts index 4ef666aba..6fc3d161e 100644 --- a/packages/server/src/tenant/tenant.seed.ts +++ b/packages/server/src/tenant/tenant.seed.ts @@ -1,8 +1,7 @@ +import { DEFAULT_TENANT, ITenant } from '@metad/contracts'; import { Connection } from 'typeorm'; import { Tenant } from './tenant.entity'; import * as faker from 'faker'; -import { DEFAULT_TENANT } from './default-tenants'; -import { ITenant } from '@metad/contracts'; export const getDefaultTenant = async ( connection: Connection, diff --git a/packages/server/src/tenant/tenant.service.ts b/packages/server/src/tenant/tenant.service.ts index 78a5e4f2a..6c80dfb61 100644 --- a/packages/server/src/tenant/tenant.service.ts +++ b/packages/server/src/tenant/tenant.service.ts @@ -8,7 +8,9 @@ import { ITenantCreateInput, RolesEnum, IUser, - FileStorageProviderEnum + FileStorageProviderEnum, + DEFAULT_TENANT, + IOrganizationCreateInput } from '@metad/contracts'; import { UserService } from '../user/user.service'; import { RoleService } from './../role/role.service'; @@ -18,7 +20,8 @@ import { ImportRecordUpdateOrCreateCommand } from './../export-import/import-rec import { User } from './../core/entities/internal'; import { TenantSettingSaveCommand } from './tenant-setting/commands'; import { TenantCreatedEvent } from './events'; -import { DEFAULT_TENANT } from './default-tenants'; +import { OrganizationCreateCommand } from '../organization/commands'; + @Injectable() export class TenantService extends CrudService { @@ -37,10 +40,10 @@ export class TenantService extends CrudService { entity: ITenantCreateInput, user: IUser ): Promise { - const { isImporting = false, sourceId = null } = entity; + const { isImporting = false, sourceId = null, defaultOrganization } = entity; //1. Create Tenant of user. - const tenant = await this.create(entity); + const tenant = await this.create({...entity, createdBy: user}); //2. Create Role/Permissions to relative tenants. await this.commandBus.execute( @@ -101,6 +104,18 @@ export class TenantService extends CrudService { ); } } + + //7. Create default organization for tenant. + if (defaultOrganization) { + const organization = await this.commandBus.execute(new OrganizationCreateCommand( + {...defaultOrganization, tenant, tenantId} as IOrganizationCreateInput)) + tenant.organizations = [organization] + } + + //8. Apply tenant created event + const _tenant = this.publisher.mergeObjectContext(tenant) + _tenant.apply(new TenantCreatedEvent(tenant.id)) + _tenant.commit() return tenant; } diff --git a/packages/server/src/user/commands/handlers/user.create.handler.ts b/packages/server/src/user/commands/handlers/user.create.handler.ts index c29f2e8e8..2e32fec1b 100644 --- a/packages/server/src/user/commands/handlers/user.create.handler.ts +++ b/packages/server/src/user/commands/handlers/user.create.handler.ts @@ -1,15 +1,32 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { IUser } from '@metad/contracts'; +import { ConfigService } from '@metad/server-config'; +import * as bcrypt from 'bcrypt'; import { UserCreateCommand } from '../user.create.command'; import { UserService } from '../../user.service'; -import { IUser } from '@metad/contracts'; @CommandHandler(UserCreateCommand) export class UserCreateHandler implements ICommandHandler { - constructor(private readonly userService: UserService) {} + protected readonly configService: ConfigService; + protected readonly saltRounds: number; + constructor(private readonly userService: UserService) { + this.configService = new ConfigService(); + this.saltRounds = this.configService.get( + 'USER_PASSWORD_BCRYPT_SALT_ROUNDS' + ) as number; + } public async execute(command: UserCreateCommand): Promise { const { input } = command; - return await this.userService.create(input); + return await this.userService.create({ + ...input, + hash: await this.getPasswordHash(input.hash), + emailVerified: true, + }); + } + + public async getPasswordHash(password: string): Promise { + return bcrypt.hash(password, this.saltRounds); } } diff --git a/packages/server/src/user/commands/user.create.command.ts b/packages/server/src/user/commands/user.create.command.ts index 91f7b2bf5..eef9bbb42 100644 --- a/packages/server/src/user/commands/user.create.command.ts +++ b/packages/server/src/user/commands/user.create.command.ts @@ -1,6 +1,9 @@ import { ICommand } from '@nestjs/cqrs'; import { IUserCreateInput } from '@metad/contracts'; +/** + * EmailVerified automatically + */ export class UserCreateCommand implements ICommand { static readonly type = '[User] Register'; diff --git a/packages/server/src/user/default-users.ts b/packages/server/src/user/default-users.ts index 053f9a49f..930513a78 100644 --- a/packages/server/src/user/default-users.ts +++ b/packages/server/src/user/default-users.ts @@ -9,7 +9,7 @@ export const DEFAULT_SUPER_ADMINS = [ firstName: 'Super', lastName: 'Admin', imageUrl: 'assets/images/avatar-default.svg', - preferredLanguage: LanguagesEnum.CHINESE, + preferredLanguage: LanguagesEnum.Chinese, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE } ] @@ -21,7 +21,7 @@ export const DEFAULT_ADMINS = [ firstName: 'Local', lastName: 'Admin', imageUrl: 'assets/images/avatar-default.svg', - preferredLanguage: LanguagesEnum.CHINESE, + preferredLanguage: LanguagesEnum.Chinese, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE } ] diff --git a/packages/server/src/user/user.seed.ts b/packages/server/src/user/user.seed.ts index 11793ca71..1e17039e5 100644 --- a/packages/server/src/user/user.seed.ts +++ b/packages/server/src/user/user.seed.ts @@ -10,13 +10,14 @@ import { IRole, ITenant, IUser, - ComponentLayoutStyleEnum + ComponentLayoutStyleEnum, + DEFAULT_TENANT } from '@metad/contracts'; import { User } from './user.entity'; import { getUserDummyImage, Role } from '../core'; import { DEFAULT_EMPLOYEES, DEFAULT_PEANUT_EMPLOYEES } from '../employee/default-employees'; import { DEFAULT_SUPER_ADMINS, DEFAULT_ADMINS, EMAIL_ADDRESS } from './default-users'; -import { DEFAULT_TENANT } from '../tenant/index'; + export const createDefaultAdminUsers = async ( connection: Connection, diff --git a/wait b/wait new file mode 100644 index 000000000..877e0c66a Binary files /dev/null and b/wait differ