@@ -19,7 +19,7 @@
|
||||
<img
|
||||
src="https://worklenz.s3.amazonaws.com/assets/screenshots/hero.png"
|
||||
alt="Worklenz"
|
||||
width="1024"
|
||||
width="1200"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -36,6 +36,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
3. **Run the frontend:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
4. Navigate to [http://localhost:4200](http://localhost:4200)
|
||||
|
||||
@@ -56,7 +57,12 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
- Create a copy of the `.env.template` file and name it `.env`.
|
||||
- Update the required fields in `.env` with the specific information.
|
||||
|
||||
4. **Install Dependencies:**
|
||||
4. **Restore Database**
|
||||
- Create a new database named `worklenz_db` on your local PostgreSQL server.
|
||||
- Update the `DATABASE_NAME` and `PASSWORD` in the `database/6_user_permission.sql` with your DB credentials.
|
||||
- Open a query console and execute the queries from the .sql files in the `database` directories, following the provided order.
|
||||
|
||||
5. **Install Dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
@@ -64,7 +70,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
This command installs all the necessary libraries required to run the project.
|
||||
|
||||
5. **Run the Development Server:**
|
||||
6. **Run the Development Server:**
|
||||
|
||||
**a. Start the TypeScript compiler:**
|
||||
|
||||
@@ -86,7 +92,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
This starts the development server allowing you to work on the project.
|
||||
|
||||
6. **Run the Production Server:**
|
||||
7. **Run the Production Server:**
|
||||
|
||||
**a. Compile TypeScript to JavaScript:**
|
||||
|
||||
|
||||
5
worklenz-backend/.dockerignore
Normal file
5
worklenz-backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.scannerwork
|
||||
coverage
|
||||
19
worklenz-backend/.editorconfig
Normal file
19
worklenz-backend/.editorconfig
Normal file
@@ -0,0 +1,19 @@
|
||||
# This file is for unifying the coding style for different editors and IDEs
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.pug,]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
[*.sql]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
57
worklenz-backend/.env.template
Normal file
57
worklenz-backend/.env.template
Normal file
@@ -0,0 +1,57 @@
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
SESSION_NAME=worklenz.sid
|
||||
SESSION_SECRET="YOUR_SESSION_SECRET_HERE"
|
||||
COOKIE_SECRET="YOUR_COOKIE_SECRET_HERE"
|
||||
|
||||
# CORS
|
||||
SOCKET_IO_CORS=http://localhost:4200
|
||||
SERVER_CORS=*
|
||||
|
||||
# Database
|
||||
DB_USER=DATABASE_USER_HERE # default : worklenz_backend (update "user-permission.sql" if needed)
|
||||
DB_PASSWORD=DATABASE_PASSWORD_HERE
|
||||
DB_NAME=DATABASE_NAME_HERE # default : worklenz_db
|
||||
DB_HOST=DATABASE_HOST_HERE # default : localhost
|
||||
DB_PORT=DATABASE_PORT_HERE # default : 5432
|
||||
DB_MAX_CLIENTS=50
|
||||
|
||||
# Google Login
|
||||
GOOGLE_CLIENT_ID="GOOGLE_CLIENT_ID_HERE"
|
||||
GOOGLE_CLIENT_SECRET="GOOGLE_CLIENT_SECRET_HERE"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:3000/secure/google/verify"
|
||||
LOGIN_FAILURE_REDIRECT="/"
|
||||
LOGIN_SUCCESS_REDIRECT="http://localhost:4200/auth/authenticate"
|
||||
|
||||
# CLI
|
||||
ANGULAR_DIST_DIR="/path/worklenz_frontend/dist/worklenz"
|
||||
ANGULAR_SRC_DIR="/path/worklenz_frontend"
|
||||
BACKEND_PUBLIC_DIR="/path/worklenz_backend/src/public"
|
||||
BACKEND_VIEWS_DIR="/path/worklenz_backend/src/views/admin"
|
||||
COMMIT_BUILD_IMMEDIATELY=true
|
||||
|
||||
# HOST
|
||||
HOSTNAME=localhost:4200
|
||||
|
||||
# SLACK
|
||||
SLACK_WEBHOOK=SLACK_WEBHOOK_HERE
|
||||
USE_PG_NATIVE=true
|
||||
|
||||
# JWT SECRET
|
||||
JWT_SECRET=JWT_SECRET_CODE_HERE
|
||||
|
||||
# AWS
|
||||
AWS_REGION="us-west-2"
|
||||
AWS_ACCESS_KEY_ID="AWS_ACCESS_KEY_ID_HERE"
|
||||
AWS_SECRET_ACCESS_KEY="AWS_SECRET_ACCESS_KEY_HERE"
|
||||
|
||||
# S3 Credentials
|
||||
REGION="us-west-2"
|
||||
BUCKET="BUCKET_NAME_HERE"
|
||||
S3_URL="S3_URL_HERE"
|
||||
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID_HERE"
|
||||
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY_HERE"
|
||||
|
||||
# SES email
|
||||
SOURCE_EMAIL="SOURCE_EMAIL_HERE" #Worklenz <noreply@worklenz.com>
|
||||
109
worklenz-backend/.eslintrc.json
Normal file
109
worklenz-backend/.eslintrc.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:security/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2017,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"spread": true,
|
||||
"experimentalObjectRestSpread": true
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"window": true,
|
||||
"document": true,
|
||||
"angular": true
|
||||
},
|
||||
"rules": {
|
||||
"constructor-super": 2,
|
||||
"no-class-assign": 2,
|
||||
"no-cond-assign": 2,
|
||||
"no-console": 1,
|
||||
"no-const-assign": 2,
|
||||
"no-constant-condition": 2,
|
||||
"no-control-regex": 2,
|
||||
"no-debugger": 2,
|
||||
"no-delete-var": 2,
|
||||
"no-dupe-args": 2,
|
||||
"no-dupe-class-members": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-empty-character-class": 2,
|
||||
"no-empty-pattern": 2,
|
||||
"no-empty": 2,
|
||||
"no-ex-assign": 2,
|
||||
"no-extra-boolean-cast": 2,
|
||||
"no-extra-semi": 2,
|
||||
"no-fallthrough": 2,
|
||||
"no-func-assign": 2,
|
||||
"no-global-assign": 2,
|
||||
"no-inner-declarations": 2,
|
||||
"no-invalid-regexp": 2,
|
||||
"no-irregular-whitespace": 2,
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-new-symbol": 2,
|
||||
"no-obj-calls": 2,
|
||||
"no-octal": 2,
|
||||
"no-redeclare": 2,
|
||||
"no-regex-spaces": 2,
|
||||
"no-self-assign": 2,
|
||||
"no-sparse-arrays": 2,
|
||||
"no-this-before-super": 2,
|
||||
"no-undef": 2,
|
||||
"no-unexpected-multiline": 2,
|
||||
"no-unreachable": 2,
|
||||
"no-unsafe-finally": 2,
|
||||
"no-unsafe-negation": 2,
|
||||
"no-unused-labels": 2,
|
||||
"no-unused-vars": 1,
|
||||
"no-useless-escape": 1,
|
||||
"require-yield": 2,
|
||||
"use-isnan": 2,
|
||||
"valid-typeof": 2,
|
||||
"no-var": 2,
|
||||
"no-eval": 2,
|
||||
"quotes": [
|
||||
2,
|
||||
"double",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"capitalized-comments": 0,
|
||||
"no-use-before-define": 2,
|
||||
"no-else-return": 2,
|
||||
"no-invalid-this": 2,
|
||||
"object-shorthand": 2,
|
||||
"quote-props": 0,
|
||||
"no-array-constructor": 2,
|
||||
"no-new-func": 2,
|
||||
"no-new-object": 2,
|
||||
"prefer-destructuring": 1,
|
||||
"prefer-template": 2,
|
||||
"no-param-reassign": 2,
|
||||
"prefer-spread": 2,
|
||||
"arrow-spacing": 2,
|
||||
"keyword-spacing": 2,
|
||||
"space-infix-ops": 2,
|
||||
"space-before-blocks": 2,
|
||||
"object-curly-spacing": 0,
|
||||
"semi": 2,
|
||||
"no-underscore-dangle": 2,
|
||||
"prefer-arrow-callback": 2,
|
||||
"prefer-const": 2
|
||||
},
|
||||
"env": {
|
||||
"node": true,
|
||||
"jest": true,
|
||||
"es6": true
|
||||
}
|
||||
}
|
||||
65
worklenz-backend/.gitignore
vendored
Normal file
65
worklenz-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
.DS_Store
|
||||
config.json
|
||||
.idea
|
||||
build
|
||||
.vscode
|
||||
*.code-workspace
|
||||
3
worklenz-backend/.gitmodules
vendored
Normal file
3
worklenz-backend/.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "worklenz-email-templates"]
|
||||
path = worklenz-email-templates
|
||||
url = "URL_HERE"
|
||||
2
worklenz-backend/.npmrc
Normal file
2
worklenz-backend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
fund=false # Don't print the trailing funding message
|
||||
26
worklenz-backend/Dockerfile
Normal file
26
worklenz-backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Use the official Node.js 18 image as a base
|
||||
FROM node:18
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install global dependencies
|
||||
RUN npm install -g ts-node typescript grunt grunt-cli
|
||||
|
||||
# Copy package.json and package-lock.json (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install app dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Run the build script to compile TypeScript to JavaScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
131
worklenz-backend/Gruntfile.js
Normal file
131
worklenz-backend/Gruntfile.js
Normal file
@@ -0,0 +1,131 @@
|
||||
module.exports = function (grunt) {
|
||||
|
||||
// Project configuration.
|
||||
grunt.initConfig({
|
||||
pkg: grunt.file.readJSON("package.json"),
|
||||
clean: {
|
||||
dist: "build"
|
||||
},
|
||||
compress: require("./grunt/grunt-compress"),
|
||||
copy: {
|
||||
main: {
|
||||
files: [
|
||||
{expand: true, cwd: "src", src: ["public/**"], dest: "build"},
|
||||
{expand: true, cwd: "src", src: ["views/**"], dest: "build"},
|
||||
{expand: true, cwd: "landing-page-assets", src: ["**"], dest: "build/public/assets"},
|
||||
{expand: true, cwd: "src", src: ["shared/sample-data.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "src", src: ["shared/templates/**"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "src", src: ["shared/postgresql-error-codes.json"], dest: "build", filter: "isFile"},
|
||||
]
|
||||
},
|
||||
packages: {
|
||||
files: [
|
||||
{expand: true, cwd: "", src: [".env"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: [".gitignore"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["release"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["jest.config.js"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["package.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["package-lock.json"], dest: "build", filter: "isFile"},
|
||||
{expand: true, cwd: "", src: ["common_modules/**"], dest: "build"}
|
||||
]
|
||||
}
|
||||
},
|
||||
sync: {
|
||||
main: {
|
||||
files: [
|
||||
{cwd: "src", src: ["views/**", "public/**"], dest: "build/"}, // makes all src relative to cwd
|
||||
],
|
||||
verbose: true,
|
||||
failOnError: true,
|
||||
compareUsing: "md5"
|
||||
}
|
||||
},
|
||||
uglify: {
|
||||
all: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
controllers: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "controllers/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
routes: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "routes/**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
},
|
||||
assets: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build",
|
||||
src: "public/assets/**/*.js",
|
||||
dest: "build"
|
||||
}]
|
||||
}
|
||||
},
|
||||
shell: {
|
||||
tsc: {
|
||||
command: "tsc --build tsconfig.prod.json"
|
||||
},
|
||||
esbuild: {
|
||||
// command: "esbuild `find src -type f -name '*.ts'` --platform=node --minify=false --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=build"
|
||||
command: "node esbuild && node cli/esbuild-patch"
|
||||
},
|
||||
tsc_dev: {
|
||||
command: "tsc --build tsconfig.json"
|
||||
},
|
||||
swagger: {
|
||||
command: "node ./cli/swagger"
|
||||
},
|
||||
inline_queries: {
|
||||
command: "node ./cli/inline-queries"
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
scripts: {
|
||||
files: ["src/**/*.ts"],
|
||||
tasks: ["shell:tsc_dev"],
|
||||
options: {
|
||||
debounceDelay: 250,
|
||||
spawn: false,
|
||||
}
|
||||
},
|
||||
other: {
|
||||
files: ["src/**/*.pug", "landing-page-assets/**"],
|
||||
tasks: ["sync"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
grunt.registerTask("clean", ["clean"]);
|
||||
grunt.registerTask("copy", ["copy:main"]);
|
||||
grunt.registerTask("swagger", ["shell:swagger"]);
|
||||
grunt.registerTask("build:tsc", ["shell:tsc"]);
|
||||
grunt.registerTask("build", ["clean", "shell:tsc", "copy:main", "compress"]);
|
||||
grunt.registerTask("build:es", ["clean", "shell:esbuild", "copy:main", "uglify:assets", "compress"]);
|
||||
grunt.registerTask("build:strict", ["clean", "shell:tsc", "copy:packages", "uglify:all", "copy:main", "compress"]);
|
||||
grunt.registerTask("dev", ["clean", "copy:main", "shell:tsc_dev", "shell:inline_queries", "watch"]);
|
||||
|
||||
// Load the plugin that provides the "uglify" task.
|
||||
grunt.loadNpmTasks("grunt-contrib-watch");
|
||||
grunt.loadNpmTasks("grunt-contrib-clean");
|
||||
grunt.loadNpmTasks("grunt-contrib-copy");
|
||||
grunt.loadNpmTasks("grunt-contrib-uglify");
|
||||
grunt.loadNpmTasks("grunt-contrib-compress");
|
||||
grunt.loadNpmTasks("grunt-shell");
|
||||
grunt.loadNpmTasks("grunt-sync");
|
||||
|
||||
// Default task(s).
|
||||
grunt.registerTask("default", []);
|
||||
};
|
||||
81
worklenz-backend/README.md
Normal file
81
worklenz-backend/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Worklenz Backend
|
||||
|
||||
1. **Open your IDE:**
|
||||
|
||||
Open the project directory in your preferred code editor or IDE like Visual Studio Code.
|
||||
|
||||
2. **Configure Environment Variables:**
|
||||
|
||||
- Create a copy of the `.env.template` file and name it `.env`.
|
||||
- Update the required fields in `.env` with the specific information.
|
||||
|
||||
3. **Restore Database**
|
||||
- Create a new database named `worklenz_db` on your local PostgreSQL server.
|
||||
- Update the `DATABASE_NAME` and `PASSWORD` in the `database/6_user_permission.sql` with your DB credentials.
|
||||
- Open a query console and execute the queries from the .sql files in the `database` directories, following the provided order.
|
||||
|
||||
4. **Install Dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This command installs all the necessary libraries required to run the project.
|
||||
|
||||
5. **Run the Development Server:**
|
||||
|
||||
**a. Start the TypeScript compiler:**
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
|
||||
```bash
|
||||
grunt dev
|
||||
```
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript.
|
||||
|
||||
**b. Start the development server:**
|
||||
|
||||
Open another separate terminal window and run the following command:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
This starts the development server allowing you to work on the project.
|
||||
|
||||
6. **Run the Production Server:**
|
||||
|
||||
**a. Compile TypeScript to JavaScript:**
|
||||
|
||||
Open a new terminal window and run the following command:
|
||||
|
||||
```bash
|
||||
grunt build
|
||||
```
|
||||
|
||||
This starts the `grunt` task runner, which compiles TypeScript code into JavaScript for production use.
|
||||
|
||||
**b. Start the production server:**
|
||||
|
||||
Once the compilation is complete, run the following command in the same terminal window:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
This starts the production server for your application.
|
||||
|
||||
### CLI
|
||||
|
||||
- Create controller: `$ node new controller Test`
|
||||
- Create angular release: `$ node new release`
|
||||
|
||||
### Developement Rules
|
||||
|
||||
- Controllers should only generate/create using the CLI (`node new controller Projects`)
|
||||
- Validations should only be done using a middleware placed under src/validators/ and used inside the routers (E.g., api-router.ts)
|
||||
- Validators should only generate/create using the CLI (`node new vaidator projects-params`)
|
||||
|
||||
## Pull submodules
|
||||
- git submodule update --init --recursive
|
||||
18
worklenz-backend/appspec.yml
Normal file
18
worklenz-backend/appspec.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
version: 0.0
|
||||
os: linux
|
||||
files:
|
||||
- source: /
|
||||
destination: "DESTINATION_HERE"
|
||||
permissions:
|
||||
- object: "OBJECT_PATH_HERE"
|
||||
pattern: "*.sh"
|
||||
# owner: root
|
||||
# group: root
|
||||
mode: 775
|
||||
type:
|
||||
- file
|
||||
hooks:
|
||||
AfterInstall:
|
||||
- location: build.sh
|
||||
timeout: 3600
|
||||
runas: root
|
||||
6
worklenz-backend/babel.config.js
Normal file
6
worklenz-backend/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: "current" } }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
};
|
||||
3
worklenz-backend/build.sh
Normal file
3
worklenz-backend/build.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
cd "PATH_HERE"
|
||||
npm ci
|
||||
npm run build
|
||||
16
worklenz-backend/cli/esbuild-patch
Normal file
16
worklenz-backend/cli/esbuild-patch
Normal file
@@ -0,0 +1,16 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const wwwFile = fs.readFileSync(path.join(__dirname, "../build/bin/www.js"), "utf8");
|
||||
|
||||
const bluebird = /(global\.Promise = require\("bluebird"\);)/g;
|
||||
const dotenvImport = /(var import_dotenv = __toESM\(require\("dotenv"\)\);)/g;
|
||||
const dotenvUse = /(import_dotenv\.default\.config\(\);)/g;
|
||||
|
||||
const out = wwwFile
|
||||
.replace(bluebird, "")
|
||||
.replace(dotenvUse, "")
|
||||
.replace(dotenvImport, `var import_dotenv = __toESM(require("dotenv"));import_dotenv.default.config();`)
|
||||
.replace(dotenvUse, `import_dotenv.default.config();global.Promise = require("bluebird");`);
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, "../build/bin/www.js"), out, "utf8");
|
||||
106
worklenz-backend/cli/generate-controller
Normal file
106
worklenz-backend/cli/generate-controller
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const arg = process.argv[2];
|
||||
|
||||
function camelize(str) {
|
||||
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => {
|
||||
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
||||
}).replace(/[\W]/g, "");
|
||||
}
|
||||
|
||||
function toPascalCase(string) {
|
||||
return `${string}`
|
||||
.toLowerCase()
|
||||
.replace(new RegExp(/[-_]+/, "g"), "")
|
||||
.replace(new RegExp(/[^\w\s]/, "g"), "")
|
||||
.replace(
|
||||
new RegExp(/\s+(.)(\w*)/, "g"),
|
||||
($1, $2, $3) => `${$2.toUpperCase() + $3}`
|
||||
)
|
||||
.replace(new RegExp(/\w/), s => s.toUpperCase());
|
||||
}
|
||||
|
||||
const name = arg.trim();
|
||||
const fileName = name.toLowerCase();
|
||||
const varName = camelize(name);
|
||||
const Controller = `${toPascalCase(name)}Controller`;
|
||||
|
||||
const content = `
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class ${Controller} extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = \`\`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = \`\`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = \`\`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = \`\`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = \`\`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const fullName = `${name}-controller`;
|
||||
|
||||
const apis = `
|
||||
import express from "express";
|
||||
|
||||
import ${Controller} from "../../controllers/${fullName}";
|
||||
|
||||
const ${varName}ApiRouter = express.Router();
|
||||
|
||||
${varName}ApiRouter.post("/", ${Controller}.create);
|
||||
${varName}ApiRouter.get("/", ${Controller}.get);
|
||||
${varName}ApiRouter.get("/:id", ${Controller}.getById);
|
||||
${varName}ApiRouter.put("/:id", ${Controller}.update);
|
||||
${varName}ApiRouter.delete("/:id", ${Controller}.deleteById);
|
||||
|
||||
export default ${varName}ApiRouter;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, "../src/controllers", `${fullName}.ts`), content.trim(), "utf8");
|
||||
fs.writeFileSync(path.join(__dirname, "../src/routes/apis/", `${fileName}-api-router.ts`), apis.trim(), "utf8");
|
||||
|
||||
let api = fs.readFileSync(path.join(__dirname, "../src/routes/apis", "index.ts"), "utf8");
|
||||
api = api.replace("\nconst api = express.Router();", `import ${varName}ApiRouter from "./${fileName}-api-router";\n\nconst api = express.Router();`);
|
||||
api = api.replace("export default api;", `api.use("/${fileName}", ${varName}ApiRouter);\n\nexport default api;`);
|
||||
fs.writeFileSync(path.join(__dirname, "../src/routes/apis", "index.ts"), api, "utf8");
|
||||
|
||||
console.log(`${fullName} generated`);
|
||||
27
worklenz-backend/cli/generate-validator
Normal file
27
worklenz-backend/cli/generate-validator
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const arg = process.argv[2];
|
||||
const name = arg.trim().toLowerCase();
|
||||
|
||||
const content = `
|
||||
import { NextFunction } from "express";
|
||||
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
|
||||
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||
const { example_name } = req.body;
|
||||
if (!example_name)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
|
||||
return next();
|
||||
}
|
||||
`;
|
||||
|
||||
const fullName = `${name}-validator`;
|
||||
fs.writeFileSync(path.join(__dirname, "../src/middlewares/validators", `${fullName}.ts`), content.trim(), "utf8");
|
||||
|
||||
console.log(`${fullName} generated`);
|
||||
19
worklenz-backend/cli/inline-queries
Normal file
19
worklenz-backend/cli/inline-queries
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Preview
|
||||
|
||||
function inline(folder) {
|
||||
const controllers = fs.readdirSync(path.join(__dirname, folder)).filter(f => f.split(".").pop() === "js");
|
||||
const replacer = (match, p1, p2, p3, offset, string) => match.split(/\n/g).map(s => s.trim()).join(" ").trim();
|
||||
for (const item of controllers) {
|
||||
const controller = fs.readFileSync(path.join(__dirname, folder, item), "utf8");
|
||||
const q = controller.replace(/(?<=q\s+=(.*?)`)([\s\S]*?)(?=`;)/g, replacer);
|
||||
fs.writeFileSync(path.join(__dirname, folder, item), q, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// inline("../build/controllers");
|
||||
// inline("../build/passport");
|
||||
111
worklenz-backend/cli/mkrelease
Normal file
111
worklenz-backend/cli/mkrelease
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/* eslint-disable no-console */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ncp } = require("ncp");
|
||||
const { exec } = require("child_process");
|
||||
|
||||
require("dotenv").config();
|
||||
|
||||
const options = {
|
||||
angular_dist_dir: process.env.ANGULAR_DIST_DIR,
|
||||
angular_src_dir: process.env.ANGULAR_SRC_DIR,
|
||||
backend_public_dir: process.env.BACKEND_PUBLIC_DIR,
|
||||
backend_views_dir: process.env.BACKEND_VIEWS_DIR,
|
||||
commit_changes: process.env.COMMIT_BUILD_IMMEDIATELY === "true"
|
||||
};
|
||||
|
||||
function run_git_commit(version) {
|
||||
if (!options.commit_changes) return;
|
||||
const message = `Frontend build (${version})`;
|
||||
console.log(message);
|
||||
exec(`git add . && git commit -m "build: ${message}"`);
|
||||
}
|
||||
|
||||
function copy_build() {
|
||||
const deleteFolderRecursive = function (p) {
|
||||
if (fs.existsSync(p)) {
|
||||
fs.readdirSync(p).forEach((file) => {
|
||||
const curPath = path.join(p, file);
|
||||
if (fs.lstatSync(curPath).isDirectory()) { // recurse
|
||||
deleteFolderRecursive(curPath);
|
||||
} else { // delete file
|
||||
fs.unlinkSync(curPath);
|
||||
}
|
||||
});
|
||||
fs.rmdirSync(p);
|
||||
}
|
||||
};
|
||||
|
||||
if (fs.existsSync(path.join(options.angular_dist_dir, "index.html"))) {
|
||||
const styles = /styles\.\w+\.css/g;
|
||||
const runtime = /runtime\.\w+\.js/g;
|
||||
const polyfills = /polyfills\.\w+\.js/g;
|
||||
// const scripts = /scripts\.\w+\.js/g;
|
||||
const main = /main\.\w+\.js/g;
|
||||
|
||||
const version = /\?v=\d+/g;
|
||||
|
||||
const layoutPath = path.join(options.backend_views_dir, "layout.pug");
|
||||
const indexHtml = fs.readFileSync(path.join(options.angular_dist_dir, "index.html"), "utf8");
|
||||
const pugLayout = fs.readFileSync(layoutPath, "utf8");
|
||||
|
||||
const v = Date.now();
|
||||
|
||||
const newLayout = pugLayout
|
||||
.replace(styles, indexHtml.match(styles)[0])
|
||||
.replace(runtime, indexHtml.match(runtime)[0])
|
||||
.replace(polyfills, indexHtml.match(polyfills)[0])
|
||||
// .replace(scripts, indexHtml.match(scripts)[0])
|
||||
.replace(main, indexHtml.match(main)[0])
|
||||
.replace(version, `?v=${v}`);
|
||||
|
||||
fs.writeFileSync(layoutPath, newLayout, "utf8");
|
||||
|
||||
if (fs.existsSync(options.backend_public_dir)) deleteFolderRecursive(options.backend_public_dir);
|
||||
fs.mkdirSync(options.backend_public_dir);
|
||||
|
||||
ncp(options.angular_dist_dir, options.backend_public_dir, (err) => {
|
||||
if (err) {
|
||||
return console.error(err);
|
||||
}
|
||||
fs.unlinkSync(path.join(options.backend_public_dir, "index.html"));
|
||||
|
||||
const versionFile = path.join(__dirname, "../", "release");
|
||||
let $v = null;
|
||||
if (fs.existsSync(versionFile)) {
|
||||
const version = fs.readFileSync(versionFile, "utf8");
|
||||
$v = +version + 1;
|
||||
fs.writeFileSync(versionFile, $v.toString(), "utf8");
|
||||
}
|
||||
console.log(`Release Updated - v${$v}`);
|
||||
console.log("Running git commit.");
|
||||
setTimeout(() => run_git_commit(`v${$v}`), 500);
|
||||
});
|
||||
} else {
|
||||
console.log("Build does not exists!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function build() {
|
||||
console.log("Start building the Angular Project");
|
||||
const cmd = exec(`cd ${options.angular_src_dir} && npm run build`);
|
||||
|
||||
cmd.stdout.on("data", (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
cmd.stderr.on("data", (data) => {
|
||||
console.log(`${data.toString()}`);
|
||||
});
|
||||
|
||||
cmd.on("exit", (code) => {
|
||||
console.log("Build success.");
|
||||
console.log("Start Coping");
|
||||
setTimeout(copy_build, 500);
|
||||
});
|
||||
}
|
||||
|
||||
build();
|
||||
21
worklenz-backend/cli/swagger
Normal file
21
worklenz-backend/cli/swagger
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const swaggerJsdoc = require("swagger-jsdoc");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Hello World",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
apis: ["../build/routes/*.js"], // files containing annotations as above
|
||||
};
|
||||
|
||||
const openapiSpecification = swaggerJsdoc(options);
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, "../build/swagger.json"), JSON.stringify(openapiSpecification), "utf8");
|
||||
1901
worklenz-backend/database/1_tables.sql
Normal file
1901
worklenz-backend/database/1_tables.sql
Normal file
File diff suppressed because it is too large
Load Diff
161
worklenz-backend/database/2_triggers.sql
Normal file
161
worklenz-backend/database/2_triggers.sql
Normal file
@@ -0,0 +1,161 @@
|
||||
-- Lowercase email
|
||||
CREATE OR REPLACE FUNCTION lower_email() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
|
||||
IF (is_null_or_empty(NEW.email) IS FALSE)
|
||||
THEN
|
||||
NEW.email = LOWER(TRIM(NEW.email));
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS users_email_lower ON users;
|
||||
CREATE TRIGGER users_email_lower
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON users
|
||||
EXECUTE FUNCTION lower_email();
|
||||
|
||||
DROP TRIGGER IF EXISTS email_invitations_email_lower ON email_invitations;
|
||||
CREATE TRIGGER email_invitations_email_lower
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON email_invitations
|
||||
EXECUTE FUNCTION lower_email();
|
||||
-- Lowercase email
|
||||
|
||||
-- Set task completed date
|
||||
CREATE OR REPLACE FUNCTION task_status_change_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = NEW.status_id)
|
||||
AND is_done IS TRUE)
|
||||
THEN
|
||||
UPDATE tasks SET completed_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
ELSE
|
||||
UPDATE tasks SET completed_at = NULL WHERE id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER tasks_status_id_change
|
||||
AFTER UPDATE OF status_id
|
||||
ON tasks
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.status_id IS DISTINCT FROM new.status_id)
|
||||
EXECUTE FUNCTION task_status_change_trigger_fn();
|
||||
-- Set task completed date
|
||||
|
||||
-- Insert notification settings for new team members
|
||||
CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
|
||||
IF (NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND
|
||||
(is_null_or_empty(NEW.user_id) IS FALSE) AND (EXISTS(SELECT 1 FROM users WHERE id = NEW.user_id))
|
||||
THEN
|
||||
INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id,
|
||||
team_id)
|
||||
VALUES (TRUE, TRUE, NEW.user_id, NEW.team_id);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS insert_notification_settings ON team_members;
|
||||
CREATE TRIGGER insert_notification_settings
|
||||
AFTER INSERT
|
||||
ON team_members
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notification_settings_insert_trigger_fn();
|
||||
-- Insert notification settings for new team members
|
||||
|
||||
-- Delete notification settings when removing team members
|
||||
CREATE OR REPLACE FUNCTION notification_settings_delete_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
DELETE FROM notification_settings WHERE user_id = OLD.user_id AND team_id = OLD.team_id;
|
||||
RETURN OLD;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS remove_notification_settings ON team_members;
|
||||
CREATE TRIGGER remove_notification_settings
|
||||
BEFORE DELETE
|
||||
ON team_members
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notification_settings_delete_trigger_fn();
|
||||
-- Delete notification settings when removing team members
|
||||
|
||||
-- Set task updated at
|
||||
CREATE OR REPLACE FUNCTION set_task_updated_at_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER set_task_updated_at
|
||||
BEFORE UPDATE
|
||||
ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_task_updated_at_trigger_fn();
|
||||
-- Set task updated at
|
||||
|
||||
-- Update project tasks counter
|
||||
CREATE OR REPLACE FUNCTION update_project_tasks_counter_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
|
||||
UPDATE projects SET tasks_counter = (tasks_counter + 1) WHERE id = NEW.project_id;
|
||||
NEW.task_no = (SELECT tasks_counter FROM projects WHERE id = NEW.project_id);
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_tasks_counter_trigger ON tasks;
|
||||
CREATE TRIGGER projects_tasks_counter_trigger
|
||||
BEFORE INSERT
|
||||
ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_project_tasks_counter_trigger_fn();
|
||||
-- Update project tasks counter
|
||||
|
||||
-- Task status change trigger
|
||||
CREATE OR REPLACE FUNCTION tasks_task_subscriber_notify_done_trigger() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
BEGIN
|
||||
IF (EXISTS(SELECT 1
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = NEW.status_id)
|
||||
AND is_done IS TRUE))
|
||||
THEN
|
||||
PERFORM pg_notify('db_task_status_changed', NEW.id::TEXT);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER tasks_task_subscriber_notify_done
|
||||
BEFORE UPDATE OF status_id
|
||||
ON tasks
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.status_id IS DISTINCT FROM NEW.status_id)
|
||||
EXECUTE FUNCTION tasks_task_subscriber_notify_done_trigger();
|
||||
-- Task status change trigger
|
||||
77
worklenz-backend/database/3_system-data.sql
Normal file
77
worklenz-backend/database/3_system-data.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_priorities() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('Low', 0, '#75c997');
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('Medium', 1, '#fbc84c');
|
||||
INSERT INTO task_priorities (name, value, color_code) VALUES ('High', 2, '#f37070');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_access_levels() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Admin', 'ADMIN');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Member', 'MEMBER');
|
||||
INSERT INTO project_access_levels (name, key)
|
||||
VALUES ('Project Manager', 'PROJECT_MANAGER');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_task_status_categories() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_todo)
|
||||
VALUES ('To do', '#a9a9a9', 0, TRUE);
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_doing)
|
||||
VALUES ('Doing', '#70a6f3', 1, TRUE);
|
||||
INSERT INTO sys_task_status_categories (name, color_code, index, is_done)
|
||||
VALUES ('Done', '#75c997', 2, TRUE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_statuses() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_project_statuses (name, color_code, icon, sort_order, is_default)
|
||||
VALUES ('Cancelled', '#f37070', 'close-circle', 0, FALSE),
|
||||
('Blocked', '#cbc8a1', 'stop', 1, FALSE),
|
||||
('On Hold', '#cbc8a1', 'stop', 2, FALSE),
|
||||
('Proposed', '#cbc8a1', 'clock-circle', 3, TRUE),
|
||||
('In Planning', '#cbc8a1', 'clock-circle', 4, FALSE),
|
||||
('In Progress', '#80ca79', 'clock-circle', 5, FALSE),
|
||||
('Completed', '#80ca79', 'check-circle', 6, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION sys_insert_project_healths() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Not Set', '#a9a9a9', 0, TRUE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Needs Attention', '#fbc84c', 1, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('At Risk', '#f37070', 2, FALSE);
|
||||
INSERT INTO sys_project_healths (name, color_code, sort_order, is_default)
|
||||
VALUES ('Good', '#75c997', 3, FALSE);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
SELECT sys_insert_task_priorities();
|
||||
SELECT sys_insert_project_access_levels();
|
||||
SELECT sys_insert_task_status_categories();
|
||||
SELECT sys_insert_project_statuses();
|
||||
SELECT sys_insert_project_healths();
|
||||
|
||||
DROP FUNCTION sys_insert_task_priorities();
|
||||
DROP FUNCTION sys_insert_project_access_levels();
|
||||
DROP FUNCTION sys_insert_task_status_categories();
|
||||
DROP FUNCTION sys_insert_project_statuses();
|
||||
DROP FUNCTION sys_insert_project_healths();
|
||||
|
||||
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||
SELECT name, abbrev, utc_offset
|
||||
FROM pg_timezone_names;
|
||||
34
worklenz-backend/database/4_views.sql
Normal file
34
worklenz-backend/database/4_views.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE VIEW task_labels_view(name, task_id, label_id) AS
|
||||
SELECT (SELECT team_labels.name
|
||||
FROM team_labels
|
||||
WHERE team_labels.id = task_labels.label_id) AS name,
|
||||
task_labels.task_id,
|
||||
task_labels.label_id
|
||||
FROM task_labels;
|
||||
|
||||
CREATE VIEW tasks_with_status_view(task_id, parent_task_id, is_todo, is_doing, is_done) AS
|
||||
SELECT tasks.id AS task_id,
|
||||
tasks.parent_task_id,
|
||||
stsc.is_todo,
|
||||
stsc.is_doing,
|
||||
stsc.is_done
|
||||
FROM tasks
|
||||
JOIN task_statuses ts ON tasks.status_id = ts.id
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE tasks.archived IS FALSE;
|
||||
|
||||
CREATE VIEW team_member_info_view(avatar_url, email, name, user_id, team_member_id, team_id) AS
|
||||
SELECT u.avatar_url,
|
||||
COALESCE(u.email, (SELECT email_invitations.email
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS email,
|
||||
COALESCE(u.name, (SELECT email_invitations.name
|
||||
FROM email_invitations
|
||||
WHERE email_invitations.team_member_id = team_members.id)) AS name,
|
||||
u.id AS user_id,
|
||||
team_members.id AS team_member_id,
|
||||
team_members.team_id
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
||||
|
||||
|
||||
5791
worklenz-backend/database/5_functions.sql
Normal file
5791
worklenz-backend/database/5_functions.sql
Normal file
File diff suppressed because it is too large
Load Diff
35
worklenz-backend/database/6_user-permission.sql
Normal file
35
worklenz-backend/database/6_user-permission.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Default ROLE : worklenz_client
|
||||
-- Default USER : worklenz_backend
|
||||
-- Change DATABASE_NAME, ROLE, PASSWORD and USER as needed.
|
||||
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
CREATE ROLE worklenz_client;
|
||||
|
||||
GRANT CONNECT ON DATABASE 'DATABASE_NAME' TO worklenz_client;
|
||||
GRANT INSERT, SELECT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON task_priorities FROM worklenz_client;
|
||||
GRANT SELECT ON task_priorities TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON project_access_levels FROM worklenz_client;
|
||||
GRANT SELECT ON project_access_levels TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON timezones FROM worklenz_client;
|
||||
GRANT SELECT ON timezones TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON worklenz_alerts FROM worklenz_client;
|
||||
GRANT SELECT ON worklenz_alerts TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_task_status_categories FROM worklenz_client;
|
||||
GRANT SELECT ON sys_task_status_categories TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_statuses FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_statuses TO worklenz_client;
|
||||
|
||||
REVOKE ALL PRIVILEGES ON sys_project_healths FROM worklenz_client;
|
||||
GRANT SELECT ON sys_project_healths TO worklenz_client;
|
||||
|
||||
CREATE USER worklenz_backend WITH PASSWORD 'PASSWORD';
|
||||
GRANT worklenz_client TO worklenz_backend;
|
||||
1
worklenz-backend/database/README.md
Normal file
1
worklenz-backend/database/README.md
Normal file
@@ -0,0 +1 @@
|
||||
All database DDLs, DMLs and migrations relates to the application should be stored here as well.
|
||||
3
worklenz-backend/doc/Database.md
Normal file
3
worklenz-backend/doc/Database.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- When you selecting user's info, like name, email, avatar etc. Use `team_member_info_view`
|
||||
- System default tables should be prefixed with `sys_`. (e.g. `sys_priorities`)
|
||||
- Tasks should be queried by skipping archived tasks
|
||||
38
worklenz-backend/esbuild.js
Normal file
38
worklenz-backend/esbuild.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
// still working on this...
|
||||
|
||||
const esbuild = require("esbuild");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function getTsFiles(directoryPath) {
|
||||
const files = fs.readdirSync(directoryPath);
|
||||
|
||||
let tsFiles = [];
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(directoryPath, file);
|
||||
const fileStat = fs.statSync(filePath);
|
||||
|
||||
if (fileStat.isFile() && path.extname(file) === ".ts") {
|
||||
tsFiles.push(filePath);
|
||||
} else if (fileStat.isDirectory()) {
|
||||
const subdirectoryTsFiles = getTsFiles(filePath);
|
||||
tsFiles = tsFiles.concat(subdirectoryTsFiles);
|
||||
}
|
||||
});
|
||||
|
||||
return tsFiles;
|
||||
}
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: getTsFiles("src"),
|
||||
platform: "node",
|
||||
minify: false,
|
||||
target: "esnext",
|
||||
format: "cjs",
|
||||
tsconfig: "tsconfig.prod.json",
|
||||
outdir: "build",
|
||||
logLevel: "debug"
|
||||
});
|
||||
28
worklenz-backend/grunt/grunt-compress.js
Normal file
28
worklenz-backend/grunt/grunt-compress.js
Normal file
@@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
brotli_js: {
|
||||
options: {
|
||||
mode: "brotli",
|
||||
brotli: {
|
||||
mode: 1
|
||||
}
|
||||
},
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
extDot: "last",
|
||||
ext: ".js.br"
|
||||
},
|
||||
gzip_js: {
|
||||
options: {
|
||||
mode: "gzip"
|
||||
},
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
ext: ".js.gz"
|
||||
}]
|
||||
}
|
||||
};
|
||||
196
worklenz-backend/jest.config.js
Normal file
196
worklenz-backend/jest.config.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
automock: true,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
coveragePathIgnorePatterns: [
|
||||
"\\\\node_modules\\\\"
|
||||
],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
// coverageProvider: "babel",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
moduleDirectories: [
|
||||
"node_modules"
|
||||
],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
restoreMocks: true,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
// testEnvironment: "jest-environment-node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
testPathIgnorePatterns: [
|
||||
"\\\\node_modules\\\\"
|
||||
],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
testResultsProcessor: "jest-sonar-reporter",
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
testEnvironmentOptions: {
|
||||
url: "http://localhost:3000"
|
||||
},
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
transformIgnorePatterns: [
|
||||
"\\\\node_modules\\\\",
|
||||
"\\.pnp\\.[^\\\\]+$"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
78
worklenz-backend/new
Normal file
78
worklenz-backend/new
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require("path");
|
||||
const slug = require("slugify");
|
||||
const { exec } = require("child_process");
|
||||
const [, , cmd, name] = process.argv;
|
||||
|
||||
function s(value) {
|
||||
return slug(value, {
|
||||
replacement: "-", // replace spaces with replacement
|
||||
remove: /[\/()._]/g, // regex to remove characters
|
||||
lower: true, // result in lower case
|
||||
});
|
||||
}
|
||||
|
||||
function log(value) {
|
||||
console.log(value);
|
||||
}
|
||||
|
||||
function generate_controller() {
|
||||
const pro = exec(`node cli/generate-controller ${s(name).replace(/controller/, "")}`);
|
||||
pro.stdout.on("data", (data) => {
|
||||
log(data.toString());
|
||||
});
|
||||
|
||||
pro.stderr.on("data", (data) => {
|
||||
log(`${data.toString()}`);
|
||||
});
|
||||
|
||||
pro.on("exit", (code) => {
|
||||
log(`Process exit with code ${code}.`);
|
||||
});
|
||||
}
|
||||
|
||||
function create_release() {
|
||||
const release = exec(`node cli/mkrelease`);
|
||||
|
||||
release.stdout.on("data", (data) => {
|
||||
log(data.toString());
|
||||
});
|
||||
|
||||
release.stderr.on("data", (data) => {
|
||||
log(`${data.toString()}`);
|
||||
});
|
||||
|
||||
release.on("exit", (code) => {
|
||||
log(`Build success with code ${code}.`);
|
||||
log("Start Coping");
|
||||
});
|
||||
}
|
||||
|
||||
function generate_validator() {
|
||||
const pro = exec(`node cli/generate-validator ${s(name).replace(/validator|validators/, "")}`);
|
||||
pro.stdout.on("data", (data) => {
|
||||
log(data.toString());
|
||||
});
|
||||
|
||||
pro.stderr.on("data", (data) => {
|
||||
log(`${data.toString()}`);
|
||||
});
|
||||
|
||||
pro.on("exit", (code) => {
|
||||
log(`Process exit with code ${code}.`);
|
||||
});
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "controller":
|
||||
generate_controller();
|
||||
break;
|
||||
case "release":
|
||||
create_release();
|
||||
break;
|
||||
case "validator":
|
||||
generate_validator();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
16333
worklenz-backend/package-lock.json
generated
Normal file
16333
worklenz-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
147
worklenz-backend/package.json
Normal file
147
worklenz-backend/package.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "worklenz-backend",
|
||||
"version": "1.4.16",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"npm": ">=8.11.0",
|
||||
"node": ">=16.13.0",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
},
|
||||
"main": "build/bin/www",
|
||||
"repository": "GITHUB_REPO_HERE",
|
||||
"author": "worklenz.com",
|
||||
"scripts": {
|
||||
"start": "node ./build/bin/www",
|
||||
"tcs": "grunt build:tsc",
|
||||
"build": "grunt build",
|
||||
"watch": "grunt watch",
|
||||
"es": "esbuild `find src -type f -name '*.ts'` --platform=node --minify=true --watch=true --target=esnext --format=cjs --tsconfig=tsconfig.prod.json --outdir=dist",
|
||||
"copy": "grunt copy",
|
||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||
"tsc": "tsc",
|
||||
"test": "jest --setupFiles dotenv/config",
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||
},
|
||||
"jestSonar": {
|
||||
"reportPath": "coverage",
|
||||
"reportFile": "test-reporter.xml",
|
||||
"indent": 4
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.378.0",
|
||||
"@aws-sdk/client-ses": "^3.378.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.378.0",
|
||||
"@aws-sdk/util-format-url": "^3.357.0",
|
||||
"axios": "^1.6.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bluebird": "^3.7.2",
|
||||
"compression": "^1.7.4",
|
||||
"connect-flash": "^0.1.1",
|
||||
"connect-pg-simple": "^7.0.0",
|
||||
"cookie-parser": "~1.4.4",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^2.4.0",
|
||||
"csurf": "^1.11.0",
|
||||
"debug": "^4.3.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.3.0",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^6.15.0",
|
||||
"helmet": "^6.2.0",
|
||||
"hpp": "^0.2.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"jsonschema": "^1.4.1",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"passport": "^0.5.3",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.11.1",
|
||||
"pg-native": "^3.0.1",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^4.6.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"segfault-handler": "^1.3.0",
|
||||
"sharp": "^0.32.6",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.1",
|
||||
"uglify-js": "^3.17.4",
|
||||
"winston": "^3.10.0",
|
||||
"xss-filters": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.22.9",
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bluebird": "^3.5.38",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express-brute": "^1.0.2",
|
||||
"@types/express-brute-redis": "^0.0.4",
|
||||
"@types/express-rate-limit": "^6.0.0",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/express-validator": "^3.0.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/hpp": "^0.2.2",
|
||||
"@types/http-errors": "^1.8.2",
|
||||
"@types/jest": "^28.1.8",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/morgan": "^1.9.4",
|
||||
"@types/node": "^18.17.1",
|
||||
"@types/passport": "^1.0.12",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-local": "^1.0.35",
|
||||
"@types/pg": "^8.10.2",
|
||||
"@types/pug": "^2.0.6",
|
||||
"@types/redis": "^4.0.11",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/swagger-jsdoc": "^6.0.1",
|
||||
"@types/toobusy-js": "^0.5.2",
|
||||
"@types/uglify-js": "^3.17.1",
|
||||
"@types/xss-filters": "^0.0.27",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"esbuild": "^0.17.19",
|
||||
"esbuild-envfile-plugin": "^1.0.5",
|
||||
"esbuild-node-externals": "^1.8.0",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-security": "^1.7.1",
|
||||
"fs-extra": "^10.1.0",
|
||||
"grunt": "^1.6.1",
|
||||
"grunt-cli": "^1.4.3",
|
||||
"grunt-contrib-clean": "^2.0.1",
|
||||
"grunt-contrib-compress": "^2.0.0",
|
||||
"grunt-contrib-copy": "^1.0.0",
|
||||
"grunt-contrib-uglify": "^5.2.2",
|
||||
"grunt-contrib-watch": "^1.1.0",
|
||||
"grunt-shell": "^4.0.0",
|
||||
"grunt-sync": "^0.8.2",
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"nodeman": "^1.1.2",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
}
|
||||
1
worklenz-backend/release
Normal file
1
worklenz-backend/release
Normal file
@@ -0,0 +1 @@
|
||||
901
|
||||
0
worklenz-backend/scss/style.scss
Normal file
0
worklenz-backend/scss/style.scss
Normal file
10
worklenz-backend/sonar-project.properties
Normal file
10
worklenz-backend/sonar-project.properties
Normal file
@@ -0,0 +1,10 @@
|
||||
sonar.projectKey="PROJECT_KEY_HERE"
|
||||
sonar.projectVersion=1.0
|
||||
sonar.host.url="SONAR_HOST_URL_HERE"
|
||||
sonar.exclusions=node_modules/**, src/public/**, scss/**, test/**
|
||||
sonar.sources=src,build
|
||||
sonor.tests=test
|
||||
sonar.test.inclusions=**/*.spec.ts,**/*.spec.js
|
||||
sonar.token="SONAR_TOKEN_HERE"
|
||||
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||
sonar.testExecutionReportPaths=coverage/test-reporter.xml
|
||||
176
worklenz-backend/src/app.ts
Normal file
176
worklenz-backend/src/app.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import createError from "http-errors";
|
||||
import express, {NextFunction, Request, Response} from "express";
|
||||
import path from "path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import logger from "morgan";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import passport from "passport";
|
||||
import csurf from "csurf";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cors from "cors";
|
||||
import uglify from "uglify-js";
|
||||
import flash from "connect-flash";
|
||||
import hpp from "hpp";
|
||||
|
||||
import passportConfig from "./passport";
|
||||
import indexRouter from "./routes/index";
|
||||
import apiRouter from "./routes/apis";
|
||||
import authRouter from "./routes/auth";
|
||||
import emailTemplatesRouter from "./routes/email-templates";
|
||||
|
||||
import public_router from "./routes/public";
|
||||
import {isInternalServer, isProduction} from "./shared/utils";
|
||||
import sessionMiddleware from "./middlewares/session-middleware";
|
||||
import {send_to_slack} from "./shared/slack";
|
||||
import {CSP_POLICIES} from "./shared/csp";
|
||||
import safeControllerFunction from "./shared/safe-controller-function";
|
||||
import AwsSesController from "./controllers/aws-ses-controller";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(compression());
|
||||
app.use(helmet({crossOriginResourcePolicy: false, crossOriginEmbedderPolicy: false}));
|
||||
|
||||
app.use((_req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||
res.removeHeader("server");
|
||||
next();
|
||||
});
|
||||
|
||||
function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
||||
return req.user ? next() : next(createError(401));
|
||||
}
|
||||
|
||||
passportConfig(passport);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require("pug").filters = {
|
||||
/**
|
||||
* ```pug
|
||||
* script
|
||||
* :minify_js
|
||||
* // JavaScript Syntax
|
||||
* ```
|
||||
* @param {String} text
|
||||
* @param {Object} options
|
||||
*/
|
||||
minify_js(text: string) {
|
||||
if (!text) return;
|
||||
// return text;
|
||||
return uglify.minify({"script.js": text}).code;
|
||||
}
|
||||
};
|
||||
|
||||
// view engine setup
|
||||
app.set("views", path.join(__dirname, "views"));
|
||||
app.set("view engine", "pug");
|
||||
|
||||
app.use(logger("dev"));
|
||||
app.use(express.json({limit: "50mb"}));
|
||||
app.use(express.urlencoded({extended: false, limit: "50mb"}));
|
||||
// Prevent HTTP Parameter Pollution
|
||||
app.use(hpp());
|
||||
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||
|
||||
app.use(cors({
|
||||
origin: [`https://${process.env.HOSTNAME}`],
|
||||
methods: "GET,PUT,POST,DELETE",
|
||||
preflightContinue: false,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.post("/-/csp", (req: express.Request, res: express.Response) => {
|
||||
send_to_slack({
|
||||
type: "⚠️ CSP Report",
|
||||
body: req.body
|
||||
});
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
app.post("/webhook/emails/bounce", safeControllerFunction(AwsSesController.handleBounceResponse));
|
||||
app.post("/webhook/emails/complaints", safeControllerFunction(AwsSesController.handleComplaintResponse));
|
||||
app.post("/webhook/emails/reply", safeControllerFunction(AwsSesController.handleReplies));
|
||||
|
||||
app.use(flash());
|
||||
app.use(csurf({cookie: true}));
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader("Content-Security-Policy", CSP_POLICIES);
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token);
|
||||
res.locals.csrf = token;
|
||||
next();
|
||||
});
|
||||
|
||||
if (isProduction()) {
|
||||
app.get("*.js", (req, res, next) => {
|
||||
if (req.header("Accept-Encoding")?.includes("br")) {
|
||||
req.url = `${req.url}.br`;
|
||||
res.set("Content-Encoding", "br");
|
||||
res.set("Content-Type", "application/javascript; charset=UTF-8");
|
||||
} else if (req.header("Accept-Encoding")?.includes("gzip")) {
|
||||
req.url = `${req.url}.gz`;
|
||||
res.set("Content-Encoding", "gzip");
|
||||
res.set("Content-Type", "application/javascript; charset=UTF-8");
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
app.set("trust proxy", 1);
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1500, // Limit each IP to 2000 requests per `window` (here, per 15 minutes)
|
||||
standardHeaders: false, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const {send} = res;
|
||||
res.send = function (obj) {
|
||||
if (req.headers.accept?.includes("application/json"))
|
||||
return send.call(this, `)]}',\n${JSON.stringify(obj)}`);
|
||||
return send.call(this, obj);
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
app.use("/secure", authRouter);
|
||||
app.use("/public", public_router);
|
||||
app.use("/api/v1", isLoggedIn, apiRouter);
|
||||
app.use("/", indexRouter);
|
||||
|
||||
if (isInternalServer())
|
||||
app.use("/email-templates", emailTemplatesRouter);
|
||||
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.locals.error_title = "404 Not Found.";
|
||||
res.locals.error_message = `The requested URL ${req.url} was not found on this server.`;
|
||||
res.locals.error_image = "/assets/images/404.webp";
|
||||
res.status(400);
|
||||
res.render("error");
|
||||
});
|
||||
|
||||
// error handler
|
||||
app.use((err: { message: string; status: number; }, _req: Request, res: Response) => {
|
||||
// set locals, only providing error in development
|
||||
res.locals.error_title = "500 Internal Server Error.";
|
||||
res.locals.error_message = "Oops, something went wrong.";
|
||||
res.locals.error_message2 = "Try to refresh this page or feel free to contact us if the problem persists.";
|
||||
res.locals.error_image = "/assets/images/500.png";
|
||||
|
||||
// render the error page
|
||||
res.status(err.status || 500);
|
||||
res.render("error");
|
||||
});
|
||||
|
||||
export default app;
|
||||
6
worklenz-backend/src/bin/config.ts
Normal file
6
worklenz-backend/src/bin/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import dotenv from "dotenv";
|
||||
import SegfaultHandler from "segfault-handler";
|
||||
|
||||
dotenv.config();
|
||||
global.Promise = require("bluebird");
|
||||
SegfaultHandler.registerHandler("crash.log");
|
||||
119
worklenz-backend/src/bin/www.ts
Normal file
119
worklenz-backend/src/bin/www.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// config should be imported at the top of this file.
|
||||
import "./config";
|
||||
|
||||
import {Server, Socket} from "socket.io";
|
||||
import http, {IncomingHttpHeaders} from "http";
|
||||
|
||||
import app from "../app";
|
||||
import {register} from "../socket.io";
|
||||
import {IO} from "../shared/io";
|
||||
import sessionMiddleware from "../middlewares/session-middleware";
|
||||
import {getLoggedInUserIdFromSocket} from "../socket.io/util";
|
||||
import {startCronJobs} from "../cron_jobs";
|
||||
import FileConstants from "../shared/file-constants";
|
||||
import {initRedis} from "../redis/client";
|
||||
import DbTaskStatusChangeListener from "../pg_notify_listeners/db-task-status-changed";
|
||||
|
||||
function normalizePort(val?: string) {
|
||||
const p = parseInt(val || "0", 10);
|
||||
if (isNaN(p)) return val; // named pipe
|
||||
if (p >= 0) return p; // port number
|
||||
return false;
|
||||
}
|
||||
|
||||
const port = normalizePort(process.env.PORT);
|
||||
app.set("port", port);
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
transports: ["websocket"],
|
||||
path: "/socket",
|
||||
cors: {
|
||||
origin: (process.env.SOCKET_IO_CORS || "*").split(",")
|
||||
},
|
||||
cookie: true
|
||||
});
|
||||
|
||||
const wrap = (middleware: any) => (socket: any, next: any) => middleware(socket.request, {}, next);
|
||||
|
||||
io.use(wrap(sessionMiddleware));
|
||||
|
||||
io.use((socket, next) => {
|
||||
const userId = getLoggedInUserIdFromSocket(socket);
|
||||
if (userId)
|
||||
return next();
|
||||
return next(new Error("401 unauthorized"));
|
||||
});
|
||||
|
||||
io.engine.on("initial_headers", (headers: IncomingHttpHeaders) => {
|
||||
headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains";
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["X-Frame-Options"] = "Deny";
|
||||
headers["X-XSS-Protection"] = "1; mode=block";
|
||||
});
|
||||
|
||||
io.on("connection", (socket: Socket) => {
|
||||
register(io, socket);
|
||||
});
|
||||
|
||||
IO.setInstance(io);
|
||||
|
||||
function onError(error: any) {
|
||||
DbTaskStatusChangeListener.disconnect();
|
||||
|
||||
if (error.syscall !== "listen") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const bind = typeof port === "string"
|
||||
? `Pipe ${port}`
|
||||
: `Port ${port}`;
|
||||
|
||||
// handle specific listen errors with friendly messages
|
||||
switch (error.code) {
|
||||
case "EACCES":
|
||||
console.error(`${bind} requires elevated privileges`);
|
||||
process.exit(1);
|
||||
break;
|
||||
case "EADDRINUSE":
|
||||
console.error(`${bind} is already in use`);
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function onListening() {
|
||||
const addr = server.address();
|
||||
if (!addr) return;
|
||||
|
||||
const bind = typeof addr === "string"
|
||||
? `pipe ${addr}`
|
||||
: `port ${addr.port}`;
|
||||
|
||||
startCronJobs();
|
||||
// TODO - uncomment initRedis()
|
||||
// void initRedis();
|
||||
FileConstants.init();
|
||||
void DbTaskStatusChangeListener.connect();
|
||||
|
||||
console.info(`Listening on ${bind}`);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
DbTaskStatusChangeListener.disconnect();
|
||||
}
|
||||
|
||||
server.on("error", onError);
|
||||
server.on("close", onClose);
|
||||
server.on("listening", onListening);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
9
worklenz-backend/src/config/db-config.ts
Normal file
9
worklenz-backend/src/config/db-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
host: process.env.DB_HOST,
|
||||
port: +(process.env.DB_PORT as string),
|
||||
max: +(process.env.DB_MAX_CLIENTS as string),
|
||||
idleTimeoutMillis: 30000,
|
||||
};
|
||||
15
worklenz-backend/src/config/db.ts
Normal file
15
worklenz-backend/src/config/db.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import pgModule, {QueryResult} from "pg";
|
||||
import dbConfig from "./db-config";
|
||||
|
||||
const pg = (process.env.USE_PG_NATIVE === "true" && pgModule.native) ? pgModule.native : pgModule;
|
||||
const pool = new pg.Pool(dbConfig);
|
||||
|
||||
pool.on("error", (err: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("pg idle client error", err, err.message, err.stack);
|
||||
});
|
||||
|
||||
export default {
|
||||
pool,
|
||||
query: (text: string, params?: unknown[]) => pool.query(text, params) as Promise<QueryResult<any>>,
|
||||
};
|
||||
0
worklenz-backend/src/controllers/.gitkeep
Normal file
0
worklenz-backend/src/controllers/.gitkeep
Normal file
@@ -0,0 +1,15 @@
|
||||
import db from "../config/db";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
export default class AccessControlsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getRoles(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, default_role, admin_role FROM roles WHERE team_id = $1 AND owner IS FALSE ORDER BY name;`;
|
||||
const result = await db.query(q, [req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
34
worklenz-backend/src/controllers/activity-logs-controller.ts
Normal file
34
worklenz-backend/src/controllers/activity-logs-controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import moment from "moment";
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {formatDuration, formatLogText, getColor} from "../shared/utils";
|
||||
|
||||
export default class ActivitylogsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
const q = `SELECT get_activity_logs_by_task($1) AS activity_logs;`;
|
||||
const result = await db.query(q, [id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const log of data.activity_logs.logs) {
|
||||
if (log.attribute_type === "estimation") {
|
||||
log.previous = formatDuration(moment.duration(log.previous, "minutes"));
|
||||
log.current = formatDuration(moment.duration(log.current, "minutes"));
|
||||
}
|
||||
if (log.assigned_user) log.assigned_user.color_code = getColor(log.assigned_user.name);
|
||||
log.done_by.color_code = getColor(log.done_by.name);
|
||||
log.log_text = await formatLogText(log);
|
||||
log.attribute_type = log.attribute_type?.replace(/_/g, " ");
|
||||
}
|
||||
data.activity_logs.color_code = getColor(data.activity_logs.name);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.activity_logs));
|
||||
}
|
||||
}
|
||||
309
worklenz-backend/src/controllers/admin-center-controller.ts
Normal file
309
worklenz-backend/src/controllers/admin-center-controller.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor} from "../shared/utils";
|
||||
import {calculateStorage} from "../shared/s3";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
import {SocketEvents} from "../socket.io/events";
|
||||
import {IO} from "../shared/io";
|
||||
|
||||
export default class AdminCenterController extends WorklenzControllerBase {
|
||||
|
||||
public static async checkIfUserActiveInOtherTeams(owner_id: string, email: string) {
|
||||
if (!owner_id) throw new Error("Owner not found.");
|
||||
|
||||
const q = `SELECT EXISTS(SELECT tmi.team_member_id
|
||||
FROM team_member_info_view AS tmi
|
||||
JOIN teams AS t ON tmi.team_id = t.id
|
||||
JOIN team_members AS tm ON tmi.team_member_id = tm.id
|
||||
WHERE tmi.email = $1::TEXT
|
||||
AND t.user_id = $2::UUID AND tm.active = true);`;
|
||||
const result = await db.query(q, [email, owner_id]);
|
||||
|
||||
const [data] = result.rows;
|
||||
return data.exists;
|
||||
}
|
||||
|
||||
// organization
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationDetails(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// const q = `SELECT organization_name AS name,
|
||||
// contact_number,
|
||||
// contact_number_secondary,
|
||||
// (SELECT email FROM users WHERE id = users_data.user_id),
|
||||
// (SELECT name FROM users WHERE id = users_data.user_id) AS owner_name
|
||||
// FROM users_data
|
||||
// WHERE user_id = $1;`;
|
||||
const q = `SELECT organization_name AS name,
|
||||
contact_number,
|
||||
contact_number_secondary,
|
||||
(SELECT email FROM users WHERE id = organizations.user_id),
|
||||
(SELECT name FROM users WHERE id = organizations.user_id) AS owner_name
|
||||
FROM organizations
|
||||
WHERE user_id = $1;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationAdmins(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT u.name, email, owner AS is_owner
|
||||
FROM users u
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN roles r ON tm.role_id = r.id
|
||||
WHERE tm.team_id IN (SELECT id FROM teams WHERE teams.user_id = $1)
|
||||
AND (admin_role IS TRUE OR owner IS TRUE)
|
||||
GROUP BY u.name, email, owner
|
||||
ORDER BY owner DESC, u.name;`;
|
||||
const result = await db.query(q, [req.user?.owner_id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationUsers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, size, offset } = this.toPaginationOptions(req.query, ["outer_tmiv.name", "outer_tmiv.email"]);
|
||||
|
||||
const q = `SELECT ROW_TO_JSON(rec) AS users
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT email,
|
||||
STRING_AGG(DISTINCT CAST(user_id AS VARCHAR), ', ') AS user_id,
|
||||
STRING_AGG(DISTINCT name, ', ') AS name,
|
||||
STRING_AGG(DISTINCT avatar_url, ', ') AS avatar_url,
|
||||
(SELECT twl.created_at
|
||||
FROM task_work_log twl
|
||||
WHERE twl.user_id IN (SELECT tmiv.user_id
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.email = outer_tmiv.email)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1) AS last_logged
|
||||
FROM team_member_info_view outer_tmiv
|
||||
WHERE outer_tmiv.team_id IN (SELECT id
|
||||
FROM teams
|
||||
WHERE teams.user_id = $1) ${searchQuery}
|
||||
GROUP BY email
|
||||
ORDER BY email LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM (SELECT DISTINCT email
|
||||
FROM team_member_info_view outer_tmiv
|
||||
WHERE outer_tmiv.team_id IN
|
||||
(SELECT id
|
||||
FROM teams
|
||||
WHERE teams.user_id = $1) ${searchQuery}) AS total) rec;`;
|
||||
const result = await db.query(q, [req.user?.owner_id, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.users));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateOrganizationName(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {name} = req.body;
|
||||
// const q = `UPDATE users_data
|
||||
// SET organization_name = $1
|
||||
// WHERE user_id = $2;`;
|
||||
const q = `UPDATE organizations
|
||||
SET organization_name = $1
|
||||
WHERE user_id = $2;`;
|
||||
const result = await db.query(q, [name, req.user?.owner_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateOwnerContactNumber(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {contact_number} = req.body;
|
||||
const q = `UPDATE organizations
|
||||
SET contact_number = $1
|
||||
WHERE user_id = $2;`;
|
||||
const result = await db.query(q, [contact_number, req.user?.owner_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = ``;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, size, offset} = this.toPaginationOptions(req.query, ["name"]);
|
||||
|
||||
let size_changed = size;
|
||||
|
||||
if (offset == 0) size_changed = size_changed - 1;
|
||||
|
||||
const currentTeamClosure = offset == 0 ? `,
|
||||
(SELECT COALESCE(ROW_TO_JSON(c), '{}'::JSON)
|
||||
FROM (SELECT id,
|
||||
name,
|
||||
created_at,
|
||||
(SELECT count(*) FROM team_members WHERE team_id = teams.id) as members_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT CASE
|
||||
WHEN u.name IS NOT NULL THEN u.name
|
||||
ELSE (SELECT name
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = team_members.id) END,
|
||||
avatar_url
|
||||
FROM team_members
|
||||
LEFT JOIN users u on team_members.user_id = u.id
|
||||
WHERE team_id = teams.id) rec) AS team_members
|
||||
FROM teams
|
||||
WHERE user_id = $1 AND teams.id = $4) c) AS current_team_data` : ``;
|
||||
|
||||
const q = `SELECT ROW_TO_JSON(rec) AS teams
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT id,
|
||||
name,
|
||||
created_at,
|
||||
(SELECT count(*) FROM team_members WHERE team_id = teams.id) as members_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT CASE
|
||||
WHEN u.name IS NOT NULL THEN u.name
|
||||
ELSE (SELECT name
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = team_members.id) END,
|
||||
avatar_url
|
||||
FROM team_members
|
||||
LEFT JOIN users u on team_members.user_id = u.id
|
||||
WHERE team_id = teams.id) rec) AS team_members
|
||||
FROM teams
|
||||
WHERE user_id = $1 AND NOT teams.id = $4 ${searchQuery}
|
||||
ORDER BY name, created_at
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
${currentTeamClosure}
|
||||
FROM teams
|
||||
WHERE user_id = $1 ${searchQuery}) rec;`;
|
||||
const result = await db.query(q, [req.user?.owner_id, size_changed, offset, req.user?.team_id]);
|
||||
|
||||
const [obj] = result.rows;
|
||||
|
||||
for (const team of obj.teams?.data || []) {
|
||||
team.names = this.createTagList(team?.team_members);
|
||||
team.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
}
|
||||
|
||||
if (obj.teams.current_team_data) {
|
||||
obj.teams.current_team_data.names = this.createTagList(obj.teams.current_team_data?.team_members);
|
||||
obj.teams.current_team_data.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, obj.teams || {}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTeamDetails(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
|
||||
const q = `SELECT id,
|
||||
name,
|
||||
created_at,
|
||||
(SELECT count(*) FROM team_members WHERE team_id = teams.id) as members_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT tm.id,
|
||||
tm.user_id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT team_member_info_view.email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT team_member_info_view.avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
role_id,
|
||||
r.name AS role_name
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u on tm.user_id = u.id
|
||||
LEFT JOIN roles r on tm.role_id = r.id
|
||||
WHERE tm.team_id = teams.id
|
||||
ORDER BY r.name = 'Owner' DESC, u.name) rec) AS team_members
|
||||
FROM teams
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
const [obj] = result.rows;
|
||||
|
||||
obj.names = this.createTagList(obj?.team_members);
|
||||
obj.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, obj || {}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateTeam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
const {name, teamMembers} = req.body;
|
||||
|
||||
const updateNameQuery = `UPDATE teams
|
||||
SET name = $1
|
||||
WHERE id = $2;`;
|
||||
await db.query(updateNameQuery, [name, id]);
|
||||
|
||||
if (teamMembers.length) {
|
||||
teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => {
|
||||
const q = `UPDATE team_members
|
||||
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
|
||||
WHERE user_id = $3
|
||||
AND team_id = $1;`;
|
||||
await db.query(q, [id, element.role_name, element.user_id]);
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteTeam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
|
||||
if (id == req.user?.team_id) {
|
||||
return res.status(200).send(new ServerResponse(true, [], "Please switch to another team before attempting deletion.")
|
||||
.withTitle("Unable to remove the presently active team!"));
|
||||
}
|
||||
|
||||
const q = `DELETE FROM teams WHERE id = $1;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {id} = req.params;
|
||||
const {teamId} = req.body;
|
||||
|
||||
if (!id || !teamId) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
|
||||
|
||||
const q = `SELECT remove_team_member($1, $2, $3) AS member;`;
|
||||
const result = await db.query(q, [id, req.user?.id, teamId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const message = `You have been removed from <b>${req.user?.team_name}</b> by <b>${req.user?.name}</b>`;
|
||||
|
||||
NotificationsService.sendNotification({
|
||||
receiver_socket_id: data.socket_id,
|
||||
message,
|
||||
team: data.team,
|
||||
team_id: id
|
||||
});
|
||||
|
||||
IO.emitByUserId(data.member.id, req.user?.id || null, SocketEvents.TEAM_MEMBER_REMOVED, {
|
||||
teamId: id,
|
||||
message
|
||||
});
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
163
worklenz-backend/src/controllers/attachment-controller.ts
Normal file
163
worklenz-backend/src/controllers/attachment-controller.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { humanFileSize, log_error, smallId } from "../shared/utils";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import {
|
||||
createPresignedUrlWithClient,
|
||||
deleteObject,
|
||||
getAvatarKey,
|
||||
getKey,
|
||||
getRootDir,
|
||||
uploadBase64,
|
||||
uploadBuffer
|
||||
} from "../shared/s3";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
const {S3_URL} = process.env;
|
||||
|
||||
if (!S3_URL) {
|
||||
log_error("Invalid S3_URL. Please check .env file.");
|
||||
}
|
||||
|
||||
export default class AttachmentController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createTaskAttachment(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { file, file_name, task_id, project_id, size, type } = req.body;
|
||||
|
||||
const q = `
|
||||
INSERT INTO task_attachments (name, task_id, team_id, project_id, uploaded_by, size, type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, size, type, created_at, CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS url;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [
|
||||
file_name,
|
||||
task_id,
|
||||
req.user?.team_id,
|
||||
project_id,
|
||||
req.user?.id,
|
||||
size,
|
||||
type,
|
||||
`${S3_URL}/${getRootDir()}`
|
||||
]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const s3Url = await uploadBase64(file, getKey(req.user?.team_id as string, project_id, data.id, data.type));
|
||||
|
||||
if (!data?.id || !s3Url)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Attachment upload failed"));
|
||||
|
||||
data.size = humanFileSize(data.size);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createAvatarAttachment(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { type, buffer } = req.body;
|
||||
|
||||
const s3Url = await uploadBuffer(buffer as Buffer, type, getAvatarKey(req.user?.id as string, type));
|
||||
|
||||
if (!s3Url)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Avatar upload failed"));
|
||||
|
||||
const q = "UPDATE users SET avatar_url = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING avatar_url;";
|
||||
const result = await db.query(q, [req.user?.id, `${s3Url}?v=${smallId(4)}`]);
|
||||
const [data] = result.rows;
|
||||
if (!data)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Avatar upload failed"));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { url: data.avatar_url }, "Avatar updated."));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
size,
|
||||
CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS url,
|
||||
type,
|
||||
created_at
|
||||
FROM task_attachments
|
||||
WHERE task_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`]);
|
||||
|
||||
for (const item of result.rows)
|
||||
item.size = humanFileSize(item.size);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { size, offset } = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS attachments
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT task_attachments.id,
|
||||
task_attachments.name,
|
||||
CONCAT((SELECT key FROM projects WHERE id = task_attachments.project_id), '-',
|
||||
(SELECT task_no FROM tasks WHERE id = task_attachments.task_id)) AS task_key,
|
||||
size,
|
||||
CONCAT($2::TEXT, '/', task_attachments.team_id, '/', task_attachments.project_id, '/',task_attachments.id,'.',type) AS url,
|
||||
task_attachments.type,
|
||||
task_attachments.created_at,
|
||||
t.name AS task_name,
|
||||
(SELECT name FROM users WHERE id = task_attachments.uploaded_by) AS uploader_name
|
||||
FROM task_attachments
|
||||
LEFT JOIN tasks t ON task_attachments.task_id = t.id
|
||||
WHERE task_attachments.project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4)t) AS data
|
||||
FROM task_attachments
|
||||
LEFT JOIN tasks t ON task_attachments.task_id = t.id
|
||||
WHERE task_attachments.project_id = $1) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const item of data?.attachments.data || [])
|
||||
item.size = humanFileSize(item.size);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.attachments || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
FROM task_attachments
|
||||
WHERE id = $1
|
||||
RETURNING CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key;`;
|
||||
const result = await db.query(q, [req.params.id, getRootDir()]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.key)
|
||||
void deleteObject(data.key);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async download(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key
|
||||
FROM task_attachments
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.query.id, getRootDir()]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.key) {
|
||||
const url = await createPresignedUrlWithClient(data.key, req.query.file as string);
|
||||
return res.status(200).send(new ServerResponse(true, url));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
}
|
||||
141
worklenz-backend/src/controllers/auth-controller.ts
Normal file
141
worklenz-backend/src/controllers/auth-controller.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import {sendResetEmail, sendResetSuccessEmail} from "../shared/email-templates";
|
||||
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {AuthResponse} from "../models/auth-response";
|
||||
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import db from "../config/db";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {PasswordStrengthChecker} from "../shared/password-strength-check";
|
||||
import FileConstants from "../shared/file-constants";
|
||||
|
||||
export default class AuthController extends WorklenzControllerBase {
|
||||
/** This just send ok response to the client when the request came here through the sign-up-validator */
|
||||
public static async status_check(_req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
public static async checkPasswordStrength(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const result = PasswordStrengthChecker.validate(req.query.password as string);
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
public static verify(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
|
||||
const errors = req.flash()["error"] || [];
|
||||
const messages = req.flash()["success"] || [];
|
||||
// If there are multiple messages, we will send one at a time.
|
||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
|
||||
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||
const title = req.query.strategy ? midTitle : null;
|
||||
|
||||
if (req.user)
|
||||
req.user.build_v = FileConstants.getRelease();
|
||||
|
||||
return res.status(200).send(new AuthResponse(title, req.isAuthenticated(), req.user || null, auth_error, message));
|
||||
}
|
||||
|
||||
public static logout(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
req.logout(() => true);
|
||||
req.session.destroy(() => {
|
||||
res.redirect("/");
|
||||
});
|
||||
}
|
||||
|
||||
private static async destroyOtherSessions(userId: string, sessionId: string) {
|
||||
try {
|
||||
const q = `DELETE FROM pg_sessions WHERE (sess ->> 'passport')::JSON ->> 'user'::TEXT = $1 AND sid != $2;`;
|
||||
await db.query(q, [userId, sessionId]);
|
||||
} catch (error) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async changePassword(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
|
||||
const currentPassword = req.body.password;
|
||||
const newPassword = req.body.new_password;
|
||||
|
||||
const q = `SELECT id, email, google_id, password FROM users WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.user?.id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data) {
|
||||
// Compare the password
|
||||
if (bcrypt.compareSync(currentPassword, data.password)) {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const encryptedPassword = bcrypt.hashSync(newPassword, salt);
|
||||
|
||||
const updatePasswordQ = `UPDATE users SET password = $1 WHERE id = $2;`;
|
||||
await db.query(updatePasswordQ, [encryptedPassword, req.user?.id || null]);
|
||||
|
||||
if (req.user?.id)
|
||||
AuthController.destroyOtherSessions(req.user.id, req.sessionID);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null, "Password updated successfully!"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(false, null, "Old password does not match!"));
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions({logWithError: "body"})
|
||||
public static async reset_password(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const {email} = req.body;
|
||||
|
||||
const q = `SELECT id, email, google_id, password FROM users WHERE email = $1;`;
|
||||
const result = await db.query(q, [email || null]);
|
||||
|
||||
if (!result.rowCount)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Account does not exists!"));
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data?.google_id) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Password reset failed!"));
|
||||
}
|
||||
|
||||
if (data?.password) {
|
||||
const userIdBase64 = Buffer.from(data.id, "utf8").toString("base64");
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hashedUserData = bcrypt.hashSync(data.id + data.email + data.password, salt);
|
||||
const hashedString = hashedUserData.toString().replace(/\//g, "-");
|
||||
|
||||
sendResetEmail(email, userIdBase64, hashedString);
|
||||
return res.status(200).send(new ServerResponse(true, null, "Password reset email has been sent to your email. Please check your email."));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(false, null, "Email not found!"));
|
||||
}
|
||||
|
||||
@HandleExceptions({logWithError: "body"})
|
||||
public static async verify_reset_email(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const {user, hash, password} = req.body;
|
||||
const hashedString = hash.replace(/\-/g, "/");
|
||||
|
||||
const userId = Buffer.from(user as string, "base64").toString("ascii");
|
||||
|
||||
const q = `SELECT id, email, google_id, password FROM users WHERE id = $1;`;
|
||||
const result = await db.query(q, [userId || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
|
||||
if (bcrypt.compareSync(data.id + data.email + data.password, hashedString)) {
|
||||
const encryptedPassword = bcrypt.hashSync(password, salt);
|
||||
const updatePasswordQ = `UPDATE users SET password = $1 WHERE id = $2;`;
|
||||
await db.query(updatePasswordQ, [encryptedPassword, userId || null]);
|
||||
|
||||
sendResetSuccessEmail(data.email);
|
||||
return res.status(200).send(new ServerResponse(true, null, "Password updated successfully"));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid Request. Please try again."));
|
||||
}
|
||||
}
|
||||
58
worklenz-backend/src/controllers/aws-ses-controller.ts
Normal file
58
worklenz-backend/src/controllers/aws-ses-controller.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {ISESBouncedMessage} from "../interfaces/aws-bounced-email-response";
|
||||
import db from "../config/db";
|
||||
import {ISESComplaintMessage} from "../interfaces/aws-complaint-email-response";
|
||||
|
||||
export default class AwsSesController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async handleBounceResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const message = JSON.parse(req.body.Message) as ISESBouncedMessage;
|
||||
|
||||
if (message.notificationType === "Bounce" && message.bounce.bounceType === "Permanent") {
|
||||
const bouncedEmails = Array.from(new Set(message.bounce.bouncedRecipients.map(r => r.emailAddress)));
|
||||
|
||||
for (const email of bouncedEmails) {
|
||||
const q = `
|
||||
INSERT INTO bounced_emails (email)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (email) DO UPDATE SET updated_at = CURRENT_TIMESTAMP;
|
||||
`;
|
||||
await db.query(q, [email]);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async handleComplaintResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const message = JSON.parse(req.body.Message) as ISESComplaintMessage;
|
||||
|
||||
if (message.notificationType === "Complaint") {
|
||||
const spamEmails = Array.from(new Set(message.complaint.complainedRecipients.map(r => r.emailAddress)));
|
||||
|
||||
for (const email of spamEmails) {
|
||||
const q = `
|
||||
INSERT INTO spam_emails (email)
|
||||
VALUES ($1)
|
||||
ON CONFLICT (email) DO UPDATE SET updated_at = CURRENT_TIMESTAMP;
|
||||
`;
|
||||
await db.query(q, [email]);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async handleReplies(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
console.log("\n");
|
||||
console.log(JSON.stringify(req.body));
|
||||
console.log("\n");
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
}
|
||||
81
worklenz-backend/src/controllers/clients-controller.ts
Normal file
81
worklenz-backend/src/controllers/clients-controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {isValidateEmail} from "../shared/utils";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {sendNewSubscriberNotification} from "../shared/email-templates";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class ClientsController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `INSERT INTO clients (name, team_id) VALUES ($1, $2);`;
|
||||
const result = await db.query(q, [req.body.name, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS clients
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT id,
|
||||
name,
|
||||
(SELECT COUNT(*) FROM projects WHERE client_id = clients.id) AS projects_count
|
||||
FROM clients
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM clients
|
||||
WHERE team_id = $1 ${searchQuery}) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.clients || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name FROM clients WHERE id = $1 AND team_id = $2`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `UPDATE clients SET name = $3 WHERE id = $1 AND team_id = $2; `;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null, req.body.name]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE FROM clients WHERE id = $1 AND team_id = $2;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async addSubscriber(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {email} = req.body;
|
||||
if (!this.isValidHost(req.hostname))
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid hostname"));
|
||||
|
||||
if (!isValidateEmail(email))
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid email address"));
|
||||
|
||||
sendNewSubscriberNotification(email);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null, "Thank you for subscribing. We'll update you once WorkLenz is live!"));
|
||||
}
|
||||
|
||||
}
|
||||
97
worklenz-backend/src/controllers/gantt-controller.ts
Normal file
97
worklenz-backend/src/controllers/gantt-controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { getColor } from "../shared/utils";
|
||||
import moment from "moment";
|
||||
|
||||
export default class GanttController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getPhaseLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT phase_label
|
||||
FROM projects
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.query.project_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id AS "TaskID",
|
||||
name AS "TaskName",
|
||||
start_date AS "StartDate",
|
||||
end_date AS "EndDate",
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)),
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT id AS "TaskID",
|
||||
name AS "TaskName",
|
||||
start_date AS "StartDate",
|
||||
end_date AS "EndDate",
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id))
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = tasks.id) rec) AS subtasks
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = $1
|
||||
AND parent_task_id IS NULL
|
||||
ORDER BY roadmap_sort_order, created_at DESC;`;
|
||||
const result = await db.query(q, [req.query.project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getPhasesByProject(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT name AS label,
|
||||
(SELECT MIN(start_date)
|
||||
FROM tasks
|
||||
WHERE id IN (SELECT task_id FROM task_phase WHERE phase_id = project_phases.id)) as day
|
||||
FROM project_phases
|
||||
WHERE project_id = $1;`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
for (const phase of result.rows) {
|
||||
phase.day = new Date(phase.day);
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getWorkload(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT pm.id AS "TaskID",
|
||||
tmiv.team_member_id,
|
||||
name AS "TaskName",
|
||||
avatar_url,
|
||||
email,
|
||||
TRUE as project_member,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT id AS "TaskID",
|
||||
name AS "TaskName",
|
||||
start_date AS "StartDate",
|
||||
end_date AS "EndDate"
|
||||
FROM tasks
|
||||
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = pm.project_id
|
||||
AND ta.team_member_id = tmiv.team_member_id
|
||||
ORDER BY roadmap_sort_order, start_date DESC) rec) AS subtasks
|
||||
FROM project_members pm
|
||||
INNER JOIN team_member_info_view tmiv ON pm.team_member_id = tmiv.team_member_id
|
||||
WHERE project_id = $1
|
||||
ORDER BY tmiv.name;`;
|
||||
const result = await db.query(q, [req.query.project_id]);
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.color_code = getColor(member.TaskName);
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
422
worklenz-backend/src/controllers/home-page-controller.ts
Normal file
422
worklenz-backend/src/controllers/home-page-controller.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import moment from "moment-timezone";
|
||||
import db from "../config/db";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import momentTime from "moment-timezone";
|
||||
|
||||
interface ITask {
|
||||
id: string,
|
||||
name: string,
|
||||
project_id: string,
|
||||
parent_task_id: string | null,
|
||||
is_sub_task: boolean,
|
||||
parent_task_name: string | null,
|
||||
status_id: string,
|
||||
start_date: string | null,
|
||||
end_date: string | null,
|
||||
created_at: string | null,
|
||||
team_id: string,
|
||||
project_name: string,
|
||||
project_color: string | null,
|
||||
status: string,
|
||||
status_color: string | null,
|
||||
is_task: boolean,
|
||||
done: boolean,
|
||||
updated_at: string | null,
|
||||
project_statuses: [{
|
||||
id: string,
|
||||
name: string | null,
|
||||
color_code: string | null,
|
||||
}]
|
||||
}
|
||||
|
||||
export default class HomePageController extends WorklenzControllerBase {
|
||||
|
||||
private static readonly GROUP_BY_ASSIGNED_TO_ME = "0";
|
||||
private static readonly GROUP_BY_ASSIGN_BY_ME = "1";
|
||||
private static readonly ALL_TAB = "All";
|
||||
private static readonly TODAY_TAB = "Today";
|
||||
private static readonly UPCOMING_TAB = "Upcoming";
|
||||
private static readonly OVERDUE_TAB = "Overdue";
|
||||
private static readonly NO_DUE_DATE_TAB = "NoDueDate";
|
||||
|
||||
private static isValidGroup(groupBy: string) {
|
||||
return groupBy === this.GROUP_BY_ASSIGNED_TO_ME
|
||||
|| groupBy === this.GROUP_BY_ASSIGN_BY_ME;
|
||||
}
|
||||
|
||||
private static isValidView(currentView: string) {
|
||||
return currentView === this.ALL_TAB
|
||||
|| currentView === this.TODAY_TAB
|
||||
|| currentView === this.UPCOMING_TAB
|
||||
|| currentView === this.OVERDUE_TAB
|
||||
|| currentView === this.NO_DUE_DATE_TAB;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createPersonalTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `INSERT INTO personal_todo_list (name, color_code, user_id, index)
|
||||
VALUES ($1, $2, $3, ((SELECT index FROM personal_todo_list ORDER BY index DESC LIMIT 1) + 1))
|
||||
RETURNING id, name`;
|
||||
const result = await db.query(q, [req.body.name, req.body.color_code, req.user?.id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
private static getTasksByGroupClosure(groupBy: string) {
|
||||
switch (groupBy) {
|
||||
case this.GROUP_BY_ASSIGN_BY_ME:
|
||||
return `AND t.id IN (
|
||||
SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE assigned_by = $2 AND team_id = $1)`;
|
||||
|
||||
case this.GROUP_BY_ASSIGNED_TO_ME:
|
||||
default:
|
||||
return `AND t.id IN (
|
||||
SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = (SELECT id FROM team_members WHERE user_id = $2 AND team_id = $1))`;
|
||||
}
|
||||
}
|
||||
|
||||
private static getTasksByTabClosure(text: string) {
|
||||
switch (text) {
|
||||
case this.TODAY_TAB:
|
||||
return `AND t.end_date::DATE = CURRENT_DATE::DATE`;
|
||||
case this.UPCOMING_TAB:
|
||||
return `AND t.end_date::DATE > CURRENT_DATE::DATE`;
|
||||
case this.OVERDUE_TAB:
|
||||
return `AND t.end_date::DATE < CURRENT_DATE::DATE`;
|
||||
case this.NO_DUE_DATE_TAB:
|
||||
return `AND t.end_date IS NULL`;
|
||||
case this.ALL_TAB:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTasksResult(groupByClosure: string, currentTabClosure: string, teamId: string, userId: string) {
|
||||
const q = `
|
||||
SELECT t.id,
|
||||
t.name,
|
||||
t.project_id,
|
||||
t.parent_task_id,
|
||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name,
|
||||
t.status_id,
|
||||
t.start_date,
|
||||
t.end_date,
|
||||
t.created_at,
|
||||
p.team_id,
|
||||
p.name AS project_name,
|
||||
p.color_code AS project_color,
|
||||
(SELECT id FROM task_statuses WHERE id = t.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
TRUE AS is_task,
|
||||
FALSE AS done,
|
||||
t.updated_at,
|
||||
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r)))
|
||||
FROM (SELECT task_statuses.id AS id,
|
||||
task_statuses.name AS name,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = task_statuses.category_id)
|
||||
FROM task_statuses
|
||||
WHERE task_statuses.project_id = t.project_id) r) AS project_statuses
|
||||
FROM tasks t
|
||||
JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.archived IS FALSE
|
||||
AND t.status_id NOT IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
${groupByClosure}
|
||||
ORDER BY t.end_date ASC`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
private static async getCountsResult(groupByClosure: string, teamId: string, userId: string) {
|
||||
const q = `SELECT COUNT(*) AS total,
|
||||
COUNT(CASE WHEN t.end_date::DATE = CURRENT_DATE::DATE THEN 1 END) AS today,
|
||||
COUNT(CASE WHEN t.end_date::DATE > CURRENT_DATE::DATE THEN 1 END) AS upcoming,
|
||||
COUNT(CASE WHEN t.end_date::DATE < CURRENT_DATE::DATE THEN 1 END) AS overdue,
|
||||
COUNT(CASE WHEN t.end_date::DATE IS NULL THEN 1 END) AS no_due_date
|
||||
FROM tasks t
|
||||
JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.archived IS FALSE
|
||||
AND t.status_id NOT IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
${groupByClosure}`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId]);
|
||||
const [row] = result.rows;
|
||||
return row;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = req.user?.team_id;
|
||||
const userId = req.user?.id;
|
||||
const timeZone = req.query.time_zone as string;
|
||||
const today = new Date();
|
||||
|
||||
const currentGroup = this.isValidGroup(req.query.group_by as string) ? req.query.group_by : this.GROUP_BY_ASSIGNED_TO_ME;
|
||||
const currentTab = this.isValidView(req.query.current_tab as string) ? req.query.current_tab : this.ALL_TAB;
|
||||
|
||||
const groupByClosure = this.getTasksByGroupClosure(currentGroup as string);
|
||||
let currentTabClosure = this.getTasksByTabClosure(currentTab as string);
|
||||
|
||||
const isCalendarView = req.query.is_calendar_view;
|
||||
|
||||
let result = await this.getTasksResult(groupByClosure, currentTabClosure, teamId as string, userId as string);
|
||||
|
||||
const counts = await this.getCountsByGroup(result, timeZone, today);
|
||||
|
||||
if (isCalendarView == "true") {
|
||||
currentTabClosure = `AND t.end_date::DATE = '${req.query.selected_date}'`;
|
||||
result = await this.groupBySingleDate(result, timeZone, req.query.selected_date as string);
|
||||
} else {
|
||||
result = await this.groupByDate(currentTab as string,result, timeZone, today);
|
||||
}
|
||||
|
||||
// const counts = await this.getCountsResult(groupByClosure, teamId as string, userId as string);
|
||||
|
||||
const data = {
|
||||
tasks: result,
|
||||
total: counts.total,
|
||||
today: counts.today,
|
||||
upcoming: counts.upcoming,
|
||||
overdue: counts.overdue,
|
||||
no_due_date: counts.no_due_date,
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
private static async groupByDate(currentTab: string,tasks: any[], timeZone: string, today: Date) {
|
||||
const formatToday = moment(today).format("YYYY-MM-DD");
|
||||
|
||||
const tasksReturn = [];
|
||||
|
||||
if (currentTab === this.ALL_TAB) {
|
||||
for (const task of tasks) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTab === this.NO_DUE_DATE_TAB) {
|
||||
for (const task of tasks) {
|
||||
if (!task.end_date) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTab === this.TODAY_TAB) {
|
||||
for (const task of tasks) {
|
||||
if (task.end_date) {
|
||||
const taskEndDate = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
if (moment(taskEndDate).isSame(formatToday)) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTab === this.UPCOMING_TAB) {
|
||||
for (const task of tasks) {
|
||||
if (task.end_date) {
|
||||
const taskEndDate = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
if (moment(taskEndDate).isAfter(formatToday)) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTab === this.OVERDUE_TAB) {
|
||||
for (const task of tasks) {
|
||||
if (task.end_date) {
|
||||
const taskEndDate = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
if (moment(taskEndDate).isBefore(formatToday)) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasksReturn;
|
||||
}
|
||||
|
||||
private static async groupBySingleDate(tasks: any, timeZone: string, selectedDate: string) {
|
||||
const formatSelectedDate = moment(selectedDate).format("YYYY-MM-DD");
|
||||
|
||||
const tasksReturn = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.end_date) {
|
||||
const taskEndDate = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
if (moment(taskEndDate).isSame(formatSelectedDate)) {
|
||||
tasksReturn.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasksReturn;
|
||||
|
||||
}
|
||||
|
||||
private static async getCountsByGroup(tasks: any[], timeZone: string, today_: Date) {
|
||||
let no_due_date = 0;
|
||||
let today = 0;
|
||||
let upcoming = 0;
|
||||
let overdue = 0;
|
||||
|
||||
const total = tasks.length;
|
||||
|
||||
const formatToday = moment(today_).format("YYYY-MM-DD");
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.end_date) {
|
||||
no_due_date = no_due_date + 1;
|
||||
}
|
||||
if (task.end_date) {
|
||||
const taskEndDate = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
if (moment(taskEndDate).isSame(formatToday)) {
|
||||
today = today + 1;
|
||||
}
|
||||
if (moment(taskEndDate).isAfter(formatToday)) {
|
||||
upcoming = upcoming + 1;
|
||||
}
|
||||
if (moment(taskEndDate).isBefore(formatToday)) {
|
||||
overdue = overdue + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
today,
|
||||
upcoming,
|
||||
overdue,
|
||||
no_due_date
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getPersonalTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const user_id = req.user?.id;
|
||||
const q = `SELECT ptl.id,
|
||||
ptl.name,
|
||||
ptl.created_at,
|
||||
FALSE AS is_task,
|
||||
ptl.done,
|
||||
ptl.updated_at
|
||||
FROM personal_todo_list ptl
|
||||
WHERE ptl.user_id = $1
|
||||
AND done IS FALSE
|
||||
ORDER BY ptl.updated_at DESC`;
|
||||
const results = await db.query(q, [user_id]);
|
||||
return res.status(200).send(new ServerResponse(true, results.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const team_id = req.user?.team_id;
|
||||
const user_id = req.user?.id;
|
||||
|
||||
const current_view = req.query.view;
|
||||
|
||||
const isFavorites = current_view === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = $2 AND project_id = projects.id)` : "";
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $2 AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $2 AND project_id = projects.id)`;
|
||||
|
||||
const q = `SELECT id,
|
||||
name,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = $2
|
||||
AND project_id = projects.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = $2
|
||||
AND project_id = projects.id) AS archived,
|
||||
color_code,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = projects.id
|
||||
AND category_id IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_id = projects.id) AS members_count,
|
||||
(SELECT get_project_members(projects.id)) AS names,
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) >
|
||||
updated_at)
|
||||
THEN (SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id)
|
||||
ELSE updated_at END) AS updated_at
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${isArchived} ${isFavorites} AND is_member_of_project(projects.id , $2
|
||||
, $1)
|
||||
ORDER BY updated_at DESC`;
|
||||
|
||||
const result = await db.query(q, [team_id, user_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectsByTeam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const team_id = req.user?.team_id;
|
||||
const user_id = req.user?.id;
|
||||
const q = `
|
||||
SELECT id, name, color_code
|
||||
FROM projects
|
||||
WHERE team_id = $1
|
||||
AND is_member_of_project(projects.id, $2, $1)
|
||||
`;
|
||||
const result = await db.query(q, [team_id, user_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updatePersonalTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE personal_todo_list
|
||||
SET done = TRUE
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`;
|
||||
await db.query(q, [req.body.id]);
|
||||
return res.status(200).send(new ServerResponse(true, req.body.id));
|
||||
}
|
||||
}
|
||||
102
worklenz-backend/src/controllers/index-controller.ts
Normal file
102
worklenz-backend/src/controllers/index-controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {NextFunction} from "express";
|
||||
import FileConstants from "../shared/file-constants";
|
||||
import {isInternalServer, isProduction, log_error} from "../shared/utils";
|
||||
import db from "../config/db";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
export default class IndexController extends WorklenzControllerBase {
|
||||
public static use(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) {
|
||||
try {
|
||||
const url = `https://${req.hostname}${req.url}`;
|
||||
res.locals.release = FileConstants.getRelease();
|
||||
res.locals.user = req.user;
|
||||
res.locals.url = url;
|
||||
res.locals.env = process.env.NODE_ENV;
|
||||
res.locals.isInternalServer = isInternalServer;
|
||||
res.locals.isProduction = isProduction;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
public static async index(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const q = `SELECT free_tier_storage, team_member_limit, projects_limit, trial_duration FROM licensing_settings;`;
|
||||
const result = await db.query(q, []);
|
||||
const [settings] = result.rows;
|
||||
res.render("index", {settings});
|
||||
}
|
||||
|
||||
public static pricing(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
res.render("pricing");
|
||||
}
|
||||
|
||||
public static privacyPolicy(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
res.render("privacy-policy");
|
||||
}
|
||||
|
||||
public static termsOfUse(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
res.render("terms-of-use");
|
||||
}
|
||||
|
||||
public static admin(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
res.render("admin");
|
||||
}
|
||||
|
||||
public static auth(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
if (req.isAuthenticated())
|
||||
return res.redirect("/worklenz");
|
||||
return res.render("admin");
|
||||
}
|
||||
|
||||
public static worklenz(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
if (req.isAuthenticated())
|
||||
return res.render("admin");
|
||||
|
||||
if (req.user && !req.user.is_member)
|
||||
return res.redirect("/teams");
|
||||
|
||||
return res.redirect("/auth");
|
||||
}
|
||||
|
||||
public static redirectToLogin(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
res.redirect("/auth/login");
|
||||
}
|
||||
|
||||
public static async signup(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const teamMemberId = req.query.user as string;
|
||||
const q = `SELECT set_active_team_by_member_id($1);`;
|
||||
await db.query(q, [teamMemberId || null]);
|
||||
} catch (error) {
|
||||
log_error(error, req.query);
|
||||
return next(createHttpError(500));
|
||||
}
|
||||
|
||||
if (req.isAuthenticated())
|
||||
return res.redirect("/worklenz");
|
||||
|
||||
return res.render("admin");
|
||||
}
|
||||
|
||||
public static async login(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) {
|
||||
// Set active team to invited team
|
||||
try {
|
||||
const teamId = req.query.team as string; // invited team id
|
||||
const userId = req.query.user as string; // invited user's id
|
||||
const q = `SELECT set_active_team($1, $2);`;
|
||||
await db.query(q, [userId || null, teamId || null]);
|
||||
} catch (error) {
|
||||
log_error(error, req.query);
|
||||
return next(createHttpError(500));
|
||||
}
|
||||
|
||||
if (req.isAuthenticated())
|
||||
return res.redirect("/worklenz");
|
||||
|
||||
return res.render("admin");
|
||||
}
|
||||
}
|
||||
62
worklenz-backend/src/controllers/job-titles-controller.ts
Normal file
62
worklenz-backend/src/controllers/job-titles-controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class JobTitlesController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {name} = req.body;
|
||||
const q = `INSERT INTO job_titles (name, team_id) VALUES ($1, (SELECT active_team FROM users WHERE id = $2::UUID));`;
|
||||
const result = await db.query(q, [name, req.user?.id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS job_titles
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT id, name
|
||||
FROM job_titles
|
||||
WHERE team_id = $1 ${searchQuery}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM job_titles
|
||||
WHERE team_id = $1 ${searchQuery}) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.job_titles || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name FROM job_titles WHERE id = $1 AND team_id = $2;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `UPDATE job_titles SET name = $1 WHERE id = $2 AND team_id = $3;`;
|
||||
const result = await db.query(q, [req.body.name, req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE FROM job_titles WHERE id = $1 AND team_id = $2;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
92
worklenz-backend/src/controllers/labels-controller.ts
Normal file
92
worklenz-backend/src/controllers/labels-controller.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes} from "../shared/constants";
|
||||
|
||||
export default class LabelsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
WITH lbs AS (SELECT id,
|
||||
name,
|
||||
color_code,
|
||||
(SELECT COUNT(*) FROM task_labels WHERE label_id = team_labels.id) AS usage,
|
||||
EXISTS(SELECT 1
|
||||
FROM task_labels
|
||||
WHERE task_labels.label_id = team_labels.id
|
||||
AND EXISTS(SELECT 1
|
||||
FROM tasks
|
||||
WHERE id = task_labels.task_id
|
||||
AND project_id = $2)) AS used
|
||||
FROM team_labels
|
||||
WHERE team_id = $1
|
||||
ORDER BY name)
|
||||
SELECT id, name, color_code, usage
|
||||
FROM lbs
|
||||
ORDER BY used DESC;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id, req.query.project || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT (SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByProject(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id, name, color_code
|
||||
FROM team_labels
|
||||
WHERE team_id = $2
|
||||
AND EXISTS(SELECT 1
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND EXISTS(SELECT 1 FROM task_labels WHERE task_id = tasks.id AND label_id = team_labels.id))
|
||||
ORDER BY name;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
|
||||
for (const label of result.rows) {
|
||||
label.color_code = label.color_code + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateColor(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `UPDATE team_labels
|
||||
SET color_code = $3
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
|
||||
if (!WorklenzColorCodes.includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id, req.body.color]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
FROM team_labels
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
26
worklenz-backend/src/controllers/logs-controller.ts
Normal file
26
worklenz-backend/src/controllers/logs-controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class LogsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getActivityLog(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT description, (SELECT name FROM projects WHERE projects.id = project_logs.project_id) AS project_name, created_at
|
||||
FROM project_logs
|
||||
WHERE team_id = $1
|
||||
AND (CASE
|
||||
WHEN (is_owner($2, $1) OR
|
||||
is_admin($2, $1)) THEN TRUE
|
||||
ELSE is_member_of_project(project_id, $2, $1) END)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
151
worklenz-backend/src/controllers/notification-controller.ts
Normal file
151
worklenz-backend/src/controllers/notification-controller.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor} from "../shared/utils";
|
||||
|
||||
export default class NotificationController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT un.id,
|
||||
un.message,
|
||||
un.created_at,
|
||||
un.read,
|
||||
(SELECT name FROM teams WHERE id = un.team_id) AS team,
|
||||
(SELECT name FROM projects WHERE id = t.project_id) AS project,
|
||||
(SELECT color_code FROM projects WHERE id = t.project_id) AS color,
|
||||
t.project_id,
|
||||
t.id AS task_id,
|
||||
un.team_id
|
||||
FROM user_notifications un
|
||||
LEFT JOIN tasks t ON un.task_id = t.id
|
||||
WHERE user_id = $1
|
||||
AND read = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.user?.id, req.query.filter === "Read"]);
|
||||
|
||||
for (const item of result.rows) {
|
||||
item.team_color = getColor(item.team_name);
|
||||
item.url = item.project_id ? `/worklenz/projects/${item.project_id}` : null;
|
||||
item.params = {task: item.task_id, tab: "tasks-list"};
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT email_notifications_enabled, popup_notifications_enabled, show_unread_items_count, daily_digest_enabled
|
||||
FROM notification_settings
|
||||
WHERE user_id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.id, req.user?.team_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const settings = {
|
||||
email_notifications_enabled: !!data?.email_notifications_enabled,
|
||||
popup_notifications_enabled: !!data?.popup_notifications_enabled,
|
||||
show_unread_items_count: !!data?.show_unread_items_count,
|
||||
daily_digest_enabled: !!data?.daily_digest_enabled
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, settings));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getUnreadCount(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT COALESCE(COUNT(*)::INTEGER, 0) AS notifications_count,
|
||||
(SELECT COALESCE(COUNT(*)::INTEGER, 0) FROM email_invitations WHERE email = (SELECT email FROM users WHERE id = $1)) AS invitations_count
|
||||
FROM user_notifications
|
||||
WHERE user_id = $1
|
||||
AND read = false
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.user?.id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data.notifications_count + data.invitations_count));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE notification_settings
|
||||
SET email_notifications_enabled = $3,
|
||||
popup_notifications_enabled = $4,
|
||||
show_unread_items_count = $5,
|
||||
daily_digest_enabled = $6
|
||||
WHERE user_id = $1
|
||||
AND team_id = $2
|
||||
RETURNING email_notifications_enabled,
|
||||
popup_notifications_enabled,
|
||||
show_unread_items_count,
|
||||
daily_digest_enabled;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [
|
||||
req.user?.id,
|
||||
req.user?.team_id,
|
||||
!!req.body.email_notifications_enabled,
|
||||
!!req.body.popup_notifications_enabled,
|
||||
!!req.body.show_unread_items_count,
|
||||
!!req.body.daily_digest_enabled
|
||||
]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const settings = {
|
||||
email_notifications_enabled: !!data?.email_notifications_enabled,
|
||||
popup_notifications_enabled: !!data?.popup_notifications_enabled,
|
||||
show_unread_items_count: !!data?.show_unread_items_count,
|
||||
daily_digest_enabled: !!data?.daily_digest_enabled
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, settings));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE user_notifications
|
||||
SET read = TRUE
|
||||
WHERE id = $1
|
||||
AND user_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async delete(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE
|
||||
FROM user_notifications
|
||||
WHERE id = $1
|
||||
AND user_id = $2
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async readAll(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE user_notifications
|
||||
SET read = TRUE
|
||||
WHERE user_id = $1
|
||||
AND read IS FALSE;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
50
worklenz-backend/src/controllers/overview-controller.ts
Normal file
50
worklenz-backend/src/controllers/overview-controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class OverviewController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
color_code,
|
||||
notes,
|
||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT team_member_id AS id,
|
||||
(SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE EXISTS(SELECT id FROM tasks WHERE project_id = $1)
|
||||
AND project_member_id = id) AS task_count,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id =
|
||||
(SELECT user_id
|
||||
FROM team_members
|
||||
WHERE team_member_id = project_members.team_member_id)),
|
||||
(SELECT name
|
||||
FROM job_titles
|
||||
WHERE id = (SELECT job_title_id
|
||||
FROM team_members
|
||||
WHERE id = project_members.team_member_id)) AS job_title
|
||||
FROM project_members
|
||||
WHERE project_id = projects.id
|
||||
ORDER BY name ASC) rec) AS members,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT id, name, done FROM tasks WHERE project_id = projects.id ORDER BY name ASC) rec) AS tasks
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class PersonalOverviewController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getTasksDueToday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT name FROM projects WHERE project_id = projects.id) AS project_name,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS status,
|
||||
(SELECT task_priorities.name FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
start_date,
|
||||
end_date
|
||||
FROM tasks t
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE t.archived IS FALSE AND t.end_date::DATE = NOW()::DATE
|
||||
AND is_member_of_project(t.project_id, $2, $1)
|
||||
ORDER BY end_date DESC
|
||||
LIMIT 5;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksRemaining(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT name FROM projects WHERE project_id = projects.id) AS project_name,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS status,
|
||||
(SELECT task_priorities.name FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
start_date,
|
||||
end_date
|
||||
FROM tasks t
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE t.archived IS FALSE AND t.end_date::DATE > NOW()::DATE
|
||||
AND is_member_of_project(t.project_id, $2, $1)
|
||||
ORDER BY end_date DESC
|
||||
LIMIT 5;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTaskOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
color_code,
|
||||
(SELECT MIN(start_date) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) AS min_date,
|
||||
(SELECT MAX(end_date) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) AS max_date
|
||||
FROM projects
|
||||
WHERE team_id = $1
|
||||
AND (CASE
|
||||
WHEN (is_owner($2, $1) OR
|
||||
is_admin($2, $1)) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, $2,
|
||||
$1) END)
|
||||
ORDER BY NAME;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import db from "../config/db";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {IPassportSession} from "../interfaces/passport-session";
|
||||
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
import {slugify} from "../shared/utils";
|
||||
import {generateProjectKey} from "../utils/generate-project-key";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
export default class ProfileSettingsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async setup(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT complete_account_setup($1, $2, $3) AS account;`;
|
||||
req.body.key = generateProjectKey(req.body.project_name, []) || null;
|
||||
const result = await db.query(q, [req.user?.id, req.user?.team_id, JSON.stringify(req.body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data)
|
||||
return res.status(200).send(new ServerResponse(false, null, "Account setup failed! Please try again"));
|
||||
|
||||
const newMembers = data.account.members || [];
|
||||
|
||||
NotificationsService.sendTeamMembersInvitations(newMembers, req.user as IPassportSession);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.account));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT name, email
|
||||
FROM users
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [req.user?.id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `UPDATE users
|
||||
SET name = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, name, email;`;
|
||||
const result = await db.query(q, [req.user?.id, req.body.name]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update_team_name(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT update_team_name($1);`;
|
||||
const body = {
|
||||
id: req.params.id,
|
||||
name: req.body.name,
|
||||
key: slugify(req.body.name),
|
||||
user_id: req.user?.id
|
||||
};
|
||||
await db.query(q, [JSON.stringify(body)]);
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { getColor } from "../shared/utils";
|
||||
import { WorklenzColorCodes } from "../shared/constants";
|
||||
|
||||
export default class ProjectCategoriesController extends WorklenzControllerBase {
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(",").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO project_categories (name, team_id, created_by, color_code)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, color_code;
|
||||
`;
|
||||
const name = req.body.name.trim();
|
||||
const result = await db.query(q, [name, req.user?.team_id, req.user?.id, name ? getColor(name) : null]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id, name, color_code, (SELECT COUNT(*) FROM projects WHERE category_id = project_categories.id) AS usage
|
||||
FROM project_categories
|
||||
WHERE team_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id, name, color_code, (SELECT COUNT(*) FROM projects WHERE category_id = project_categories.id) AS usage
|
||||
FROM project_categories
|
||||
WHERE team_id = $1;`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
private static async getTeamsByOrg(teamId: string) {
|
||||
const q = `SELECT id FROM teams WHERE in_organization(id, $1)`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByMultipleTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const teams = await this.getTeamsByOrg(req.user?.team_id as string);
|
||||
const teamIds = teams.map(team => team.id).join(",");
|
||||
|
||||
const q = `SELECT id, name, color_code FROM project_categories WHERE team_id IN (${this.flatString(teamIds)});`;
|
||||
|
||||
const result = await db.query(q);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE project_categories
|
||||
SET color_code = $2
|
||||
WHERE id = $1
|
||||
AND team_id = $3;
|
||||
`;
|
||||
|
||||
if (!WorklenzColorCodes.includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.body.color, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE
|
||||
FROM project_categories
|
||||
WHERE id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
241
worklenz-backend/src/controllers/project-comments-controller.ts
Normal file
241
worklenz-backend/src/controllers/project-comments-controller.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor, slugify} from "../shared/utils";
|
||||
import { HTML_TAG_REGEXP } from "../shared/constants";
|
||||
import { IProjectCommentEmailNotification } from "../interfaces/comment-email-notification";
|
||||
import { sendProjectComment } from "../shared/email-notifications";
|
||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||
import { IO } from "../shared/io";
|
||||
import { SocketEvents } from "../socket.io/events";
|
||||
|
||||
interface IMailConfig {
|
||||
message: string;
|
||||
receiverEmail: string;
|
||||
receiverName: string;
|
||||
content: string;
|
||||
teamName: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
interface IMention {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default class ProjectCommentsController extends WorklenzControllerBase {
|
||||
|
||||
private static replaceContent(messageContent: string, mentions: { id: string; name: string }[]) {
|
||||
const mentionNames = mentions.map(mention => mention.name);
|
||||
|
||||
const replacedContent = mentionNames.reduce(
|
||||
(content, mentionName, index) => {
|
||||
const regex = new RegExp(`@${mentionName}`, "g");
|
||||
return content.replace(regex, `{${index}}`);
|
||||
},
|
||||
messageContent
|
||||
);
|
||||
|
||||
return replacedContent;
|
||||
}
|
||||
|
||||
private static async sendMail(config: IMailConfig) {
|
||||
const subject = config.message.replace(HTML_TAG_REGEXP, "");
|
||||
|
||||
const data: IProjectCommentEmailNotification = {
|
||||
greeting: `Hi ${config.receiverName}`,
|
||||
summary: subject,
|
||||
team: config.teamName,
|
||||
project_name: config.projectName,
|
||||
comment: config.content
|
||||
};
|
||||
|
||||
await sendProjectComment(config.receiverEmail, data);
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
const mentions: IMention[] = req.body.mentions;
|
||||
const projectId = req.body.project_id;
|
||||
const teamId = req.user?.team_id;
|
||||
|
||||
let commentContent = req.body.content;
|
||||
if (mentions.length > 0) {
|
||||
commentContent = await this.replaceContent(commentContent, mentions);
|
||||
}
|
||||
|
||||
const body = {
|
||||
project_id : projectId,
|
||||
created_by: userId,
|
||||
content: commentContent,
|
||||
mentions,
|
||||
team_id: teamId
|
||||
};
|
||||
|
||||
const q = `SELECT create_project_comment($1) AS comment`;
|
||||
const result = await db.query(q, [JSON.stringify(body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const projectMembers = await this.getMembersList(projectId);
|
||||
|
||||
const commentMessage = `<b>${req.user?.name}</b> added a comment on <b>${data.comment.project_name}</b> (${data.comment.team_name})`;
|
||||
|
||||
for (const member of projectMembers || []) {
|
||||
if (member.id && member.id === req.user?.id) continue;
|
||||
NotificationsService.createNotification({
|
||||
userId: member.id,
|
||||
teamId: req.user?.team_id as string,
|
||||
socketId: member.socket_id,
|
||||
message: commentMessage,
|
||||
taskId: null,
|
||||
projectId
|
||||
});
|
||||
if (member.id !== req.user?.id && member.socket_id) {
|
||||
IO.emit(SocketEvents.NEW_PROJECT_COMMENT_RECEIVED, member.socket_id, true);
|
||||
}
|
||||
}
|
||||
|
||||
const mentionMessage = `<b>${req.user?.name}</b> has mentioned you in a comment on <b>${data.comment.project_name}</b> (${data.comment.team_name})`;
|
||||
const rdMentions = [...new Set(req.body.mentions || [])] as IMention[]; // remove duplicates
|
||||
|
||||
for (const mention of rdMentions) {
|
||||
if (mention) {
|
||||
const member = await this.getUserDataByUserId(mention.id, projectId, teamId as string);
|
||||
NotificationsService.sendNotification({
|
||||
team: data.comment.team_name,
|
||||
receiver_socket_id: member.socket_id,
|
||||
message: mentionMessage,
|
||||
task_id: "",
|
||||
project_id: projectId,
|
||||
project: data.comment.project_name,
|
||||
project_color: member.project_color,
|
||||
team_id: req.user?.team_id as string
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
private static async getUserDataByUserId(informedBy: string, projectId: string, team_id: string) {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
email,
|
||||
socket_id,
|
||||
(SELECT email_notifications_enabled
|
||||
FROM notification_settings
|
||||
WHERE notification_settings.team_id = $3
|
||||
AND notification_settings.user_id = $1),
|
||||
(SELECT color_code FROM projects WHERE id = $2) AS project_color
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [informedBy, projectId, team_id]);
|
||||
const [data] = result.rows;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static async getMembersList(projectId: string) {
|
||||
const q = `
|
||||
SELECT
|
||||
tm.user_id AS id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
(SELECT socket_id FROM users WHERE users.id = tm.user_id) AS socket_id,
|
||||
(SELECT email_notifications_enabled
|
||||
FROM notification_settings
|
||||
WHERE team_id = tm.team_id
|
||||
AND notification_settings.user_id = tm.user_id) AS email_notifications_enabled
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1 AND tm.user_id IS NOT NULL
|
||||
ORDER BY name
|
||||
`;
|
||||
const result = await db.query(q, [projectId]);
|
||||
const members = result.rows;
|
||||
return members;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const members = await this.getMembersList(req.params.id as string);
|
||||
return res.status(200).send(new ServerResponse(true, members || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const limit = req.query.isLimit;
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
pc.id,
|
||||
pc.content AS content,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM project_comment_mentions pcm
|
||||
LEFT JOIN users u ON pcm.informed_by = u.id
|
||||
WHERE pcm.comment_id = pc.id) rec) AS mentions,
|
||||
(SELECT id FROM users WHERE id = pc.created_by) AS user_id,
|
||||
(SELECT name FROM users WHERE id = pc.created_by) AS created_by,
|
||||
(SELECT avatar_url FROM users WHERE id = pc.created_by),
|
||||
pc.created_at,
|
||||
pc.updated_at
|
||||
FROM project_comments pc
|
||||
WHERE pc.project_id = $1 ORDER BY pc.updated_at DESC
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
const data = result.rows;
|
||||
|
||||
for (const comment of data) {
|
||||
const {mentions} = comment;
|
||||
if (mentions.length > 0) {
|
||||
const placeHolders = comment.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
comment.content = await comment.content.replace(/\n/g, "</br>");
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < comment.mentions.length) {
|
||||
comment.content = comment.content.replace(placeHolder, `<span class='mentions'>@${comment.mentions[index].user_name}</span>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const color_code = getColor(comment.created_by);
|
||||
comment.color_code = color_code;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCountByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT COUNT(*) AS total FROM project_comments WHERE project_id = $1`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, parseInt(data.total)));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE FROM project_comments WHERE id = $1 RETURNING id`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
}
|
||||
103
worklenz-backend/src/controllers/project-folders-controller.ts
Normal file
103
worklenz-backend/src/controllers/project-folders-controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {slugify} from "../shared/utils";
|
||||
import {IProjectFolder} from "../interfaces/project-folder";
|
||||
|
||||
export default class ProjectFoldersController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO project_folders (name, key, created_by, team_id, color_code)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, key, color_code;
|
||||
`;
|
||||
|
||||
const name = req.body.name?.trim() || null;
|
||||
const key = slugify(name);
|
||||
const createdBy = req.user?.id ?? null;
|
||||
const teamId = req.user?.team_id ?? null;
|
||||
const colorCode = req.body.color_code?.trim() || "#70a6f3";
|
||||
|
||||
const result = await db.query(q, [name, key, createdBy, teamId, colorCode]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse<IProjectFolder>(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const parentFolderId = (req.query.parent as string)?.trim() || null;
|
||||
|
||||
const q = [
|
||||
`SELECT id,
|
||||
name,
|
||||
key,
|
||||
color_code,
|
||||
created_at,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE user_id = project_folders.created_by
|
||||
AND team_member_info_view.team_id = project_folders.team_id) AS created_by
|
||||
FROM project_folders
|
||||
WHERE team_id = $1
|
||||
`,
|
||||
parentFolderId ? `AND parent_folder_id = $2` : "",
|
||||
`ORDER BY name;`
|
||||
].join(" ");
|
||||
const params = parentFolderId ? [req.user?.team_id, parentFolderId] : [req.user?.team_id];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
return res.status(200).send(new ServerResponse<IProjectFolder[]>(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id, key, name, color_code
|
||||
FROM project_folders
|
||||
WHERE key = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse<IProjectFolder>(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE project_folders
|
||||
SET name = $2,
|
||||
key = $3,
|
||||
color_code = COALESCE($5, color_code),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
AND team_id = $4
|
||||
RETURNING id, name, key;
|
||||
`;
|
||||
|
||||
const name = req.body.name?.trim() || null;
|
||||
const key = slugify(name);
|
||||
const colorCode = req.body.color_code?.trim() || null;
|
||||
|
||||
const result = await db.query(q, [req.params.id, name, key, req.user?.team_id, colorCode]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse<IProjectFolder>(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE
|
||||
FROM project_folders
|
||||
WHERE id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class ProjectHealthController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async get(_req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, color_code, is_default FROM sys_project_healths ORDER BY sort_order;`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
343
worklenz-backend/src/controllers/project-insights-controller.ts
Normal file
343
worklenz-backend/src/controllers/project-insights-controller.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {PriorityColorCodes, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {formatDuration, getColor} from "../shared/utils";
|
||||
import moment from "moment";
|
||||
|
||||
export default class ProjectInsightsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `SELECT get_project_overview_data($1, $2) AS overview;`;
|
||||
const result = await db.query(q, [req.params.id, archived === "true"]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const {total_minutes_sum, time_spent_sum} = data.overview;
|
||||
|
||||
const totalMinutes = moment.duration(total_minutes_sum, "minutes");
|
||||
const totalSeconds = moment.duration(time_spent_sum, "seconds");
|
||||
|
||||
data.overview.total_estimated_hours_string = formatDuration(totalMinutes);
|
||||
data.overview.total_logged_hours_string = formatDuration(totalSeconds);
|
||||
|
||||
data.overview.overlogged_hours = formatDuration(totalMinutes.subtract(totalSeconds));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.overview));
|
||||
}
|
||||
|
||||
public static async getMemberInsightsByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `SELECT get_project_member_insights($1, $2) AS overview;`;
|
||||
const result = await db.query(q, [req.params.id, archived === "true"]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data.overview));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getLastUpdatedtasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `SELECT get_last_updated_tasks_by_project($1, $2, $3, $4) AS last_updated;`;
|
||||
const result = await db.query(q, [req.params.id, 10, 0, archived]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const task of data.last_updated) {
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.last_updated));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectLogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT description, created_at
|
||||
FROM project_logs
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3;`;
|
||||
const result = await db.query(q, [req.params.id, 10, 0]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getStatusOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `
|
||||
SELECT task_statuses.id,
|
||||
task_statuses.name,
|
||||
stsc.color_code
|
||||
FROM task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
|
||||
WHERE project_id = $1
|
||||
AND team_id = $2
|
||||
ORDER BY task_statuses.sort_order;`;
|
||||
const status = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
const statusCounts = [];
|
||||
|
||||
for (const element of status.rows) {
|
||||
const q = `SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE status_id = $1
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END;`;
|
||||
const count = await db.query(q, [element.id, archived === "true"]);
|
||||
const [data] = count.rows;
|
||||
statusCounts.push({name: element.name, color: element.color_code, y: parseInt(data.count)});
|
||||
element.status_color = element.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, statusCounts || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getPriorityOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `SELECT id, name, value
|
||||
FROM task_priorities
|
||||
ORDER BY value;`;
|
||||
const result = await db.query(q, []);
|
||||
for (const item of result.rows)
|
||||
item.color_code = PriorityColorCodes[item.value] || PriorityColorCodes["0"];
|
||||
|
||||
const statusCounts = [];
|
||||
|
||||
for (const element of result.rows) {
|
||||
const q = `SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE priority_id = $1
|
||||
AND CASE
|
||||
WHEN ($3 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND project_id = $2;`;
|
||||
const count = await db.query(q, [element.id, req.params.id, archived === "true"]);
|
||||
const [data] = count.rows;
|
||||
statusCounts.push({name: element.name, color: element.color_code, data: [parseInt(data.count)]});
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, statusCounts || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOverdueTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
status_id AS status,
|
||||
end_date,
|
||||
priority_id AS priority,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
updated_at,
|
||||
NOW()::DATE - end_date::DATE AS days_overdue,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND end_date::DATE < NOW()::DATE
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
AND category_id IN
|
||||
(SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE sys_task_status_categories.is_done IS FALSE));
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, archived]);
|
||||
|
||||
for (const element of result.rows) {
|
||||
element.status_color = element.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksFinishedEarly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
status_id AS status,
|
||||
end_date,
|
||||
priority_id AS priority,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
updated_at,
|
||||
completed_at,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND completed_at::DATE < end_date::DATE
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
AND category_id IN
|
||||
(SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE sys_task_status_categories.is_done IS TRUE));
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, archived]);
|
||||
|
||||
for (const element of result.rows) {
|
||||
element.status_color = element.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksFinishedLate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
status_id AS status,
|
||||
end_date,
|
||||
priority_id AS priority,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
updated_at,
|
||||
completed_at,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND completed_at::DATE > end_date::DATE
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
AND category_id IN
|
||||
(SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE sys_task_status_categories.is_done IS TRUE));
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, archived]);
|
||||
|
||||
for (const element of result.rows) {
|
||||
element.status_color = element.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksByProjectMember(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {member_id, project_id, archived} = req.body;
|
||||
const q = `SELECT get_tasks_by_project_member($1, $2, $3)`;
|
||||
const result = await db.query(q, [project_id || null, member_id || null, archived]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const element of data.get_tasks_by_project_member) {
|
||||
element.status_color = element.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
element.total_minutes = formatDuration(moment.duration(~~(element.total_minutes), "minutes"));
|
||||
element.overlogged_time = formatDuration(moment.duration(element.overlogged_time, "seconds"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.get_tasks_by_project_member || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectDeadlineStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
const q = `SELECT get_project_deadline_tasks($1, $2);`;
|
||||
const result = await db.query(q, [req.params.id || null, archived === "true"]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const task of data.get_project_deadline_tasks.tasks) {
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
const logged_hours = data.get_project_deadline_tasks.deadline_logged_hours || 0; // in seconds
|
||||
data.get_project_deadline_tasks.deadline_logged_hours_string = formatDuration(moment.duration(logged_hours, "seconds"));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.get_project_deadline_tasks || {}));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOverloggedTasksByProject(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
|
||||
/**
|
||||
SELECT id,
|
||||
name,
|
||||
status_id AS status,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
end_date,
|
||||
priority_id AS priority,
|
||||
updated_at,
|
||||
((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = tasks.id) - total_minutes) AS overlogged_time,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND total_minutes < (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = tasks.id);
|
||||
*/
|
||||
const q = `
|
||||
WITH work_log AS (SELECT task_id, SUM(time_spent) AS total_time_spent
|
||||
FROM task_work_log
|
||||
GROUP BY task_id)
|
||||
SELECT id,
|
||||
name,
|
||||
status_id AS status,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
end_date,
|
||||
priority_id AS priority,
|
||||
updated_at,
|
||||
(work_log.total_time_spent - (total_minutes * 60)) AS overlogged_time,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||
FROM tasks
|
||||
JOIN work_log ON work_log.task_id = tasks.id
|
||||
WHERE project_id = $1
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND total_minutes < work_log.total_time_spent;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.params.id || null, archived]);
|
||||
|
||||
for (const task of result.rows) {
|
||||
task.overlogged_time_string = formatDuration(moment.duration(task.overlogged_time, "seconds"));
|
||||
task.assignees.map((a: any) => a.color_code = getColor(a.name));
|
||||
task.names = this.createTagList(task.assignees);
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import db from "../config/db";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
|
||||
|
||||
export default class ProjectManagersController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getByOrg(_req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// const q = `SELECT DISTINCT (SELECT user_id from team_member_info_view tmv WHERE tmv.team_member_id = pm.team_member_id),
|
||||
// team_member_id,
|
||||
// (SELECT name from team_member_info_view tmv WHERE tmv.team_member_id = pm.team_member_id)
|
||||
// FROM project_members pm
|
||||
// WHERE project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')
|
||||
// AND pm.project_id IN (SELECT id FROM projects WHERE team_id IN (SELECT id FROM teams WHERE in_organization(id, $1)));`;
|
||||
// const q = `SELECT DISTINCT tmv.user_id,
|
||||
// tmv.name,
|
||||
// pm.team_member_id
|
||||
// FROM team_member_info_view tmv
|
||||
// INNER JOIN project_members pm ON tmv.team_member_id = pm.team_member_id
|
||||
// INNER JOIN projects p ON pm.project_id = p.id
|
||||
// WHERE pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')
|
||||
// AND p.team_id IN (SELECT id FROM teams WHERE in_organization(id, $1))`;
|
||||
const q = `SELECT DISTINCT ON (tm.user_id)
|
||||
tm.user_id AS id,
|
||||
u.name,
|
||||
pm.team_member_id
|
||||
FROM
|
||||
projects p
|
||||
JOIN project_members pm ON p.id = pm.project_id
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN team_members tm ON pm.team_member_id = tm.id
|
||||
JOIN team_member_info_view tmi ON tm.id = tmi.team_member_id
|
||||
JOIN users u ON tm.user_id = u.id
|
||||
WHERE
|
||||
t.id IN (SELECT id FROM teams WHERE in_organization(id, $1))
|
||||
AND pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')
|
||||
GROUP BY
|
||||
tm.user_id, u.name, pm.team_member_id`;
|
||||
const result = await db.query(q, [_req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
150
worklenz-backend/src/controllers/project-members-controller.ts
Normal file
150
worklenz-backend/src/controllers/project-members-controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getColor} from "../shared/utils";
|
||||
import TeamMembersController from "./team-members-controller";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
|
||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
|
||||
public static async checkIfUserAlreadyExists(owner_id: string, email: string) {
|
||||
if (!owner_id) throw new Error("Owner not found.");
|
||||
|
||||
const q = `SELECT EXISTS(SELECT tmi.team_member_id
|
||||
FROM team_member_info_view AS tmi
|
||||
JOIN teams AS t ON tmi.team_id = t.id
|
||||
WHERE tmi.email = $1::TEXT
|
||||
AND t.user_id = $2::UUID);`;
|
||||
const result = await db.query(q, [email, owner_id]);
|
||||
|
||||
const [data] = result.rows;
|
||||
return data.exists;
|
||||
}
|
||||
|
||||
public static async createOrInviteMembers(body: any) {
|
||||
if (!body) return;
|
||||
|
||||
const q = `SELECT create_project_member($1) AS res;`;
|
||||
|
||||
const result = await db.query(q, [JSON.stringify(body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const response = data.res;
|
||||
|
||||
if (response?.notification && response?.member_user_id) {
|
||||
NotificationsService.sendNotification({
|
||||
receiver_socket_id: response.socket_id,
|
||||
project: response.project,
|
||||
message: response.notification,
|
||||
project_color: response.project_color,
|
||||
project_id: response.project_id,
|
||||
team: response.team,
|
||||
team_id: body.team_id
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
req.body.user_id = req.user?.id;
|
||||
req.body.team_id = req.user?.team_id;
|
||||
req.body.access_level = req.body.access_level ? req.body.access_level : "MEMBER";
|
||||
const data = await this.createOrInviteMembers(req.body);
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": "Member already have a pending invitation that has not been accepted."
|
||||
}
|
||||
})
|
||||
public static async createByEmail(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
req.body.user_id = req.user?.id;
|
||||
req.body.team_id = req.user?.team_id;
|
||||
|
||||
if (!req.user?.team_id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
|
||||
// Adding as a team member
|
||||
const teamMemberReq: { team_id?: string; emails: string[], project_id?: string; } = {
|
||||
team_id: req.user?.team_id,
|
||||
emails: [req.body.email]
|
||||
};
|
||||
|
||||
if (req.body.project_id)
|
||||
teamMemberReq.project_id = req.body.project_id;
|
||||
|
||||
const [member] = await TeamMembersController.createOrInviteMembers(teamMemberReq, req.user);
|
||||
|
||||
if (!member)
|
||||
return res.status(200).send(new ServerResponse(true, null, "Failed to add the member to the project. Please try again."));
|
||||
|
||||
// Adding to the project
|
||||
const projectMemberReq = {
|
||||
team_member_id: member.team_member_id,
|
||||
team_id: req.user?.team_id,
|
||||
project_id: req.body.project_id,
|
||||
user_id: req.user?.id,
|
||||
access_level: req.body.access_level ? req.body.access_level : "MEMBER"
|
||||
};
|
||||
const data = await this.createOrInviteMembers(projectMemberReq);
|
||||
return res.status(200).send(new ServerResponse(true, data.member));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT project_members.id,
|
||||
tm.id AS team_member_id,
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_id = project_members.team_member_id) AS name,
|
||||
u.avatar_url,
|
||||
jt.name AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN job_titles jt ON tm.job_title_id = jt.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
ORDER BY project_members.created_at DESC;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
result.rows.forEach((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
public static async checkIfMemberExists(projectId: string, teamMemberId: string) {
|
||||
const q = `SELECT EXISTS(SELECT id FROM project_members WHERE project_id = $1::UUID AND team_member_id = $2::UUID)`;
|
||||
const result = await db.query(q, [projectId, teamMemberId]);
|
||||
const [data] = result.rows;
|
||||
return data.exists;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT remove_project_member($1, $2, $3) AS res;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.id, req.user?.team_id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const response = data.res;
|
||||
|
||||
if (response?.notification && response?.member_user_id) {
|
||||
NotificationsService.sendNotification({
|
||||
receiver_socket_id: response.socket_id,
|
||||
project: response.project,
|
||||
message: response.notification,
|
||||
project_color: response.project_color,
|
||||
project_id: response.project_id,
|
||||
team: response.team,
|
||||
team_id: req.user?.team_id as string
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import moment, { Moment } from "moment";
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import momentTime from "moment-timezone";
|
||||
|
||||
export const GroupBy = {
|
||||
STATUS: "status",
|
||||
PRIORITY: "priority",
|
||||
LABELS: "labels",
|
||||
PHASE: "phase"
|
||||
};
|
||||
|
||||
export interface IRMTaskGroup {
|
||||
id?: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
tasks: any[];
|
||||
is_expanded: boolean;
|
||||
}
|
||||
|
||||
export default class RoadmapTasksControllerV2Base extends WorklenzControllerBase {
|
||||
|
||||
public static updateTaskViewModel(task: any, globalStartDate: Moment, globalDateWidth: number , timeZone: string) {
|
||||
|
||||
if (typeof task.sub_tasks_count === "undefined") task.sub_tasks_count = "0";
|
||||
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.show_sub_tasks = false;
|
||||
|
||||
if (task.start_date)
|
||||
task.start_date = momentTime.tz(task.start_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (task.end_date)
|
||||
task.end_date = momentTime.tz(task.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
this.setTaskCss(task, globalStartDate, globalDateWidth);
|
||||
|
||||
task.isVisible = true;
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
private static setTaskCss(task: any, globalStartDate: Moment, globalDateWidth: number ) {
|
||||
let startDate = task.start_date ? moment(task.start_date).format("YYYY-MM-DD") : moment();
|
||||
let endDate = task.end_date ? moment(task.end_date).format("YYYY-MM-DD") : moment();
|
||||
|
||||
if (!task.start_date) {
|
||||
startDate = moment(task.end_date).format("YYYY-MM-DD");
|
||||
}
|
||||
if (!task.end_date) {
|
||||
endDate = moment(task.start_date).format("YYYY-MM-DD");
|
||||
}
|
||||
if (!task.start_date && !task.end_date) {
|
||||
startDate = moment().format("YYYY-MM-DD");
|
||||
endDate = moment().format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
const fStartDate = moment(startDate);
|
||||
const fEndDate = moment(endDate);
|
||||
const fGlobalStartDate = moment(globalStartDate).format("YYYY-MM-DD");
|
||||
|
||||
const daysDifferenceFromStart = fStartDate.diff(fGlobalStartDate, "days");
|
||||
task.offset_from = daysDifferenceFromStart * globalDateWidth;
|
||||
|
||||
if (moment(fStartDate).isSame(moment(fEndDate), "day")) {
|
||||
task.width = globalDateWidth;
|
||||
} else {
|
||||
const taskWidth = fEndDate.diff(fStartDate, "days");
|
||||
task.width = (taskWidth + 1) * globalDateWidth;
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import {ParsedQs} from "qs";
|
||||
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../../models/server-response";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../../shared/constants";
|
||||
import {getColor} from "../../shared/utils";
|
||||
import RoadmapTasksControllerV2Base, {GroupBy, IRMTaskGroup} from "./roadmap-tasks-contoller-v2-base";
|
||||
import moment, {Moment} from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
|
||||
export class TaskListGroup implements IRMTaskGroup {
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
color_code: string;
|
||||
tasks: any[];
|
||||
is_expanded: boolean;
|
||||
|
||||
constructor(group: any) {
|
||||
this.name = group.name;
|
||||
this.category_id = group.category_id || null;
|
||||
this.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
this.tasks = [];
|
||||
this.is_expanded = group.is_expanded;
|
||||
}
|
||||
}
|
||||
|
||||
export default class RoadmapTasksControllerV2 extends RoadmapTasksControllerV2Base {
|
||||
|
||||
private static GLOBAL_DATE_WIDTH = 35;
|
||||
private static GLOBAL_START_DATE = moment().format("YYYY-MM-DD");
|
||||
private static GLOBAL_END_DATE = moment().format("YYYY-MM-DD");
|
||||
|
||||
private static async getFirstLastDates(projectId: string) {
|
||||
|
||||
const q = `SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(start_date) AS min_date, MAX(start_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1 AND tasks.archived IS FALSE
|
||||
UNION
|
||||
SELECT MIN(end_date) AS min_date, MAX(end_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1 AND tasks.archived IS FALSE) AS date_union;`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
private static validateEndDate(endDate: Moment): boolean {
|
||||
return moment(endDate.format("YYYY-MM-DD")).isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static validateStartDate(startDate: Moment): boolean {
|
||||
return moment(startDate.format("YYYY-MM-DD")).isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static getScrollAmount(startDate: Moment) {
|
||||
const today = moment().format("YYYY-MM-DD");
|
||||
const daysDifference = moment(today).diff(startDate, "days");
|
||||
|
||||
return (this.GLOBAL_DATE_WIDTH * daysDifference);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createDateRange(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const dateRange = await this.getFirstLastDates(req.params.id as string);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
let startDate = moment(today).clone().startOf("month");
|
||||
let endDate = moment(today).clone().endOf("month");
|
||||
|
||||
if (dateRange.start_date)
|
||||
dateRange.start_date = momentTime.tz(dateRange.start_date, `${req.query.timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (dateRange.end_date)
|
||||
dateRange.end_date = momentTime.tz(dateRange.end_date, `${req.query.timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (dateRange.start_date && dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month");
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month");
|
||||
} else if (dateRange.start_date && !dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month");
|
||||
} else if (!dateRange.start_date && dateRange.end_date) {
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month");
|
||||
}
|
||||
|
||||
const xMonthsBeforeStart = startDate.clone().subtract(2, "months");
|
||||
const xMonthsAfterEnd = endDate.clone().add(3, "months");
|
||||
|
||||
this.GLOBAL_START_DATE = moment(xMonthsBeforeStart).format("YYYY-MM-DD");
|
||||
this.GLOBAL_END_DATE = moment(xMonthsAfterEnd).format("YYYY-MM-DD");
|
||||
|
||||
const dateData = [];
|
||||
let days = -1;
|
||||
|
||||
const currentDate = xMonthsBeforeStart.clone();
|
||||
|
||||
while (currentDate.isBefore(xMonthsAfterEnd)) {
|
||||
const monthData = {
|
||||
month: currentDate.format("MMM YYYY"),
|
||||
weeks: [] as number[],
|
||||
days: [] as { day: number, name: string, isWeekend: boolean, isToday: boolean }[],
|
||||
};
|
||||
const daysInMonth = currentDate.daysInMonth();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayOfMonth = currentDate.date();
|
||||
const dayName = currentDate.format("ddd");
|
||||
const isWeekend = [0, 6].includes(currentDate.day());
|
||||
const isToday = moment(moment(today).format("YYYY-MM-DD")).isSame(moment(currentDate).format("YYYY-MM-DD"));
|
||||
monthData.days.push({day: dayOfMonth, name: dayName, isWeekend, isToday});
|
||||
currentDate.add(1, "day");
|
||||
days++;
|
||||
}
|
||||
dateData.push(monthData);
|
||||
}
|
||||
|
||||
const scrollBy = this.getScrollAmount(xMonthsBeforeStart);
|
||||
|
||||
const result = {
|
||||
date_data: dateData,
|
||||
width: days + 1,
|
||||
scroll_by: scrollBy,
|
||||
chart_start: moment(this.GLOBAL_START_DATE).format("YYYY-MM-DD"),
|
||||
chart_end: moment(this.GLOBAL_END_DATE).format("YYYY-MM-DD")
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
private static isCountsOnly(query: ParsedQs) {
|
||||
return query.count === "true";
|
||||
}
|
||||
|
||||
public static isTasksOnlyReq(query: ParsedQs) {
|
||||
return RoadmapTasksControllerV2.isCountsOnly(query) || query.parent_task;
|
||||
}
|
||||
|
||||
|
||||
private static getQuery(userId: string, options: ParsedQs) {
|
||||
const searchField = options.search ? "t.name" : "sort_order";
|
||||
const {searchQuery} = RoadmapTasksControllerV2.toPaginationOptions(options, searchField);
|
||||
|
||||
const isSubTasks = !!options.parent_task;
|
||||
|
||||
const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE";
|
||||
|
||||
let subTasksFilter;
|
||||
|
||||
if (options.isSubtasksInclude === "true") {
|
||||
subTasksFilter = "";
|
||||
} else {
|
||||
subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL";
|
||||
}
|
||||
|
||||
const filters = [
|
||||
subTasksFilter,
|
||||
(isSubTasks ? "1 = 1" : archivedFilter),
|
||||
].filter(i => !!i).join(" AND ");
|
||||
|
||||
return `
|
||||
SELECT id,
|
||||
name,
|
||||
t.project_id AS project_id,
|
||||
t.parent_task_id,
|
||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id)::INT AS sub_tasks_count,
|
||||
|
||||
t.status_id AS status,
|
||||
t.archived,
|
||||
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category,
|
||||
|
||||
(CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END) AS parent_task_completed,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE tt.parent_task_id = t.id
|
||||
AND tt.is_done IS TRUE)::INT
|
||||
AS completed_sub_tasks,
|
||||
|
||||
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
start_date,
|
||||
end_date
|
||||
FROM tasks t
|
||||
WHERE ${filters} ${searchQuery} AND project_id = $1
|
||||
ORDER BY t.start_date ASC NULLS LAST`;
|
||||
}
|
||||
|
||||
public static async getGroups(groupBy: string, projectId: string): Promise<IRMTaskGroup[]> {
|
||||
let q = "";
|
||||
let params: any[] = [];
|
||||
switch (groupBy) {
|
||||
case GroupBy.STATUS:
|
||||
q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id),
|
||||
category_id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
ORDER BY sort_order;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
case GroupBy.PRIORITY:
|
||||
q = `SELECT id, name, color_code
|
||||
FROM task_priorities
|
||||
ORDER BY value DESC;`;
|
||||
break;
|
||||
case GroupBy.LABELS:
|
||||
q = `
|
||||
SELECT id, name, color_code
|
||||
FROM team_labels
|
||||
WHERE team_id = $2
|
||||
AND EXISTS(SELECT 1
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND EXISTS(SELECT 1 FROM task_labels WHERE task_id = tasks.id AND label_id = team_labels.id))
|
||||
ORDER BY name;
|
||||
`;
|
||||
break;
|
||||
case GroupBy.PHASE:
|
||||
q = `
|
||||
SELECT id, name, color_code, start_date, end_date
|
||||
FROM project_phases
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await db.query(q, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
|
||||
const q = RoadmapTasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const tasks = [...result.rows];
|
||||
|
||||
const groups = await this.getGroups(groupBy, req.params.id);
|
||||
|
||||
const map = groups.reduce((g: { [x: string]: IRMTaskGroup }, group) => {
|
||||
if (group.id)
|
||||
g[group.id] = new TaskListGroup(group);
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
this.updateMapByGroup(tasks, groupBy, map, req.query.expandedGroups as string, req.query.timezone as string);
|
||||
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
|
||||
if (groupBy === GroupBy.PHASE)
|
||||
group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
if (req.query.expandedGroups) {
|
||||
const expandedGroup = updatedGroups.find(g => g.id === req.query.expandedGroups);
|
||||
if (expandedGroup) expandedGroup.is_expanded = true;
|
||||
} else {
|
||||
updatedGroups[0].is_expanded = true;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: {
|
||||
[p: string]: IRMTaskGroup
|
||||
}, expandedGroup: string, timeZone: string) {
|
||||
let index = 0;
|
||||
const unmapped = [];
|
||||
for (const task of tasks) {
|
||||
task.index = index++;
|
||||
RoadmapTasksControllerV2.updateTaskViewModel(task, moment(this.GLOBAL_START_DATE), this.GLOBAL_DATE_WIDTH, timeZone);
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
map[task.status]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PRIORITY) {
|
||||
map[task.priority]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PHASE && task.phase_id) {
|
||||
map[task.phase_id]?.tasks.push(task);
|
||||
} else {
|
||||
unmapped.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmapped.length) {
|
||||
map[UNMAPPED] = {
|
||||
name: UNMAPPED,
|
||||
category_id: null,
|
||||
color_code: "#f0f0f0",
|
||||
tasks: unmapped,
|
||||
is_expanded: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const q = RoadmapTasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
const result = await db.query(q, params);
|
||||
|
||||
let data: any[] = [];
|
||||
|
||||
// if true, we only return the record count
|
||||
if (this.isCountsOnly(req.query)) {
|
||||
[data] = result.rows;
|
||||
} else { // else we return a flat list of tasks
|
||||
data = [...result.rows];
|
||||
for (const task of data) {
|
||||
RoadmapTasksControllerV2.updateTaskViewModel(task, moment(this.GLOBAL_START_DATE), this.GLOBAL_DATE_WIDTH, req.query.timeZone as string);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class ProjectstatusesController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async get(_req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, color_code, icon, is_default FROM sys_project_statuses ORDER BY sort_order;`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface IProjectTemplateLabel {
|
||||
label_id?: string;
|
||||
name?: string;
|
||||
color_code?: string;
|
||||
}
|
||||
|
||||
export interface IProjectTemplate {
|
||||
name?: string;
|
||||
id?: string;
|
||||
key?: string;
|
||||
description?: string;
|
||||
phase_label?: string;
|
||||
phases?: any;
|
||||
tasks?: any;
|
||||
status?: any;
|
||||
}
|
||||
|
||||
export interface IProjectTemplatePhase {
|
||||
id?: string;
|
||||
name?: string;
|
||||
color_code?: string;
|
||||
}
|
||||
|
||||
export interface IProjectTemplateStatus {
|
||||
id?: string;
|
||||
name?: string;
|
||||
category_id?: string;
|
||||
category_name?: string;
|
||||
sort_order?: string;
|
||||
}
|
||||
|
||||
export interface IProjectTaskPhase {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface IProjectTemplateTask {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
total_minutes?: number;
|
||||
sort_order?: number;
|
||||
priority_id?: string;
|
||||
priority_name?: string;
|
||||
new?: number;
|
||||
parent_task_id?: string | null;
|
||||
status_id?: string;
|
||||
status_name?: string;
|
||||
phase_id?: string;
|
||||
phase_name?: string;
|
||||
phases?: IProjectTaskPhase[];
|
||||
labels?: IProjectTemplateLabel[];
|
||||
task_no?: number;
|
||||
original_task_id?: string;
|
||||
}
|
||||
|
||||
export interface ITaskIncludes {
|
||||
status?: boolean;
|
||||
phase?: boolean;
|
||||
labels?: boolean;
|
||||
estimation?: boolean;
|
||||
description?: boolean;
|
||||
subtasks?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomProjectTemplate {
|
||||
name?: string;
|
||||
phase_label?: string;
|
||||
color_code?: string;
|
||||
notes?: string;
|
||||
team_id?: string;
|
||||
}
|
||||
|
||||
export interface ICustomTemplatePhase {
|
||||
name?: string;
|
||||
color_code?: string;
|
||||
template_id?: string;
|
||||
}
|
||||
|
||||
export interface ICustomTemplateTask {
|
||||
name?: string;
|
||||
description: string;
|
||||
total_minutes: string;
|
||||
sort_order: string;
|
||||
priority_id: string;
|
||||
template_id: string;
|
||||
parent_task_id: string;
|
||||
status_id?: string;
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { logStatusChange } from "../../services/activity-logs/activity-logs.service";
|
||||
import { getColor, int, log_error } from "../../shared/utils";
|
||||
import { generateProjectKey } from "../../utils/generate-project-key";
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import { ICustomProjectTemplate, ICustomTemplatePhase, IProjectTemplate, IProjectTemplateLabel, IProjectTemplatePhase, IProjectTemplateStatus, IProjectTemplateTask, ITaskIncludes } from "./interfaces";
|
||||
|
||||
export default abstract class ProjectTemplatesControllerBase extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertProjectTemplate(body: IProjectTemplate) {
|
||||
const { name, key, description, phase_label } = body;
|
||||
|
||||
const q = `INSERT INTO pt_project_templates(name, key, description, phase_label) VALUES ($1, $2, $3, $4) RETURNING id;`;
|
||||
const result = await db.query(q, [name, key, description, phase_label]);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTemplateProjectPhases(body: IProjectTemplatePhase[], template_id: string) {
|
||||
for await (const phase of body) {
|
||||
const { name, color_code } = phase;
|
||||
|
||||
const q = `INSERT INTO pt_phases(name, color_code, template_id) VALUES ($1, $2, $3);`;
|
||||
await db.query(q, [name, color_code, template_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTemplateProjectStatuses(body: IProjectTemplateStatus[], template_id: string) {
|
||||
for await (const status of body) {
|
||||
const { name, category_name, category_id } = status;
|
||||
|
||||
const q = `INSERT INTO pt_statuses(name, template_id, category_id)
|
||||
VALUES ($1, $2, (SELECT id FROM sys_task_status_categories WHERE sys_task_status_categories.name = $3));`;
|
||||
await db.query(q, [name, template_id, category_name]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTemplateProjectTasks(body: IProjectTemplateTask[], template_id: string) {
|
||||
for await (const template_task of body) {
|
||||
const { name, description, total_minutes, sort_order, priority_name, parent_task_id, phase_name, status_name } = template_task;
|
||||
|
||||
const q = `INSERT INTO pt_tasks(name, description, total_minutes, sort_order, priority_id, template_id, parent_task_id, status_id)
|
||||
VALUES ($1, $2, $3, $4, (SELECT id FROM task_priorities WHERE task_priorities.name = $5), $6, $7,
|
||||
(SELECT id FROM pt_statuses WHERE pt_statuses.name = $8 AND pt_statuses.template_id = $6)) RETURNING id;`;
|
||||
const result = await db.query(q, [name, description, total_minutes, sort_order, priority_name, template_id, parent_task_id, status_name]);
|
||||
const [task] = result.rows;
|
||||
|
||||
await this.insertTemplateTaskPhases(task.id, template_id, phase_name);
|
||||
if (template_task.labels) await this.insertTemplateTaskLabels(task.id, template_task.labels);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTemplateTaskPhases(task_id: string, template_id: string, phase_name = "") {
|
||||
const q = `INSERT INTO pt_task_phases (task_id, phase_id) VALUES ($1, (SELECT id FROM pt_phases WHERE template_id = $2 AND name = $3));`;
|
||||
await db.query(q, [task_id, template_id, phase_name]);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTemplateTaskLabels(task_id: string, labels: IProjectTemplateLabel[]) {
|
||||
for await (const label of labels) {
|
||||
const q = `INSERT INTO pt_task_labels(task_id, label_id) VALUES ($1, (SELECT id FROM pt_labels WHERE name = $2));`;
|
||||
await db.query(q, [task_id, label.name]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getTemplateData(template_id: string) {
|
||||
const q = `SELECT id,
|
||||
name,
|
||||
description,
|
||||
phase_label,
|
||||
image_url,
|
||||
color_code,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name, color_code FROM pt_phases WHERE template_id = pt.id) rec) AS phases,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name,
|
||||
category_id,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE sys_task_status_categories.id = pt_statuses.category_id)
|
||||
FROM pt_statuses
|
||||
WHERE template_id = pt.id) rec) AS status,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name, pt_labels.color_code
|
||||
FROM pt_labels
|
||||
WHERE id IN (SELECT label_id
|
||||
FROM pt_task_labels pttl
|
||||
WHERE task_id IN (SELECT id
|
||||
FROM pt_tasks
|
||||
WHERE pt_tasks.template_id = pt.id))) rec) AS labels,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name,
|
||||
color_code
|
||||
FROM task_priorities) rec) AS priorities,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name,
|
||||
(SELECT name FROM pt_statuses WHERE status_id = pt_statuses.id) AS status_name,
|
||||
(SELECT name FROM task_priorities tp WHERE priority_id = tp.id ) AS priority_name,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name
|
||||
FROM pt_phases pl
|
||||
WHERE pl.id =
|
||||
(SELECT phase_id FROM pt_task_phases WHERE task_id = pt_tasks.id)) rec) AS phases,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name
|
||||
FROM pt_labels pl
|
||||
LEFT JOIN pt_task_labels pttl ON pl.id = pttl.label_id
|
||||
WHERE pttl.task_id = pt_tasks.id) rec) AS labels
|
||||
FROM pt_tasks
|
||||
WHERE template_id = pt.id) rec) AS tasks
|
||||
FROM pt_project_templates pt
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [template_id]);
|
||||
const [data] = result.rows;
|
||||
for (const phase of data.phases) {
|
||||
phase.color_code = getColor(phase.name);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getCustomTemplateData(template_id: string) {
|
||||
const q = `SELECT id,
|
||||
name,
|
||||
notes AS description,
|
||||
phase_label,
|
||||
color_code,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name, color_code FROM cpt_phases WHERE template_id = pt.id) rec) AS phases,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name,
|
||||
category_id,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE sys_task_status_categories.id = cpts.category_id)
|
||||
FROM cpt_task_statuses cpts
|
||||
WHERE template_id = pt.id ORDER BY sort_order) rec) AS status,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name, tl.color_code
|
||||
FROM team_labels tl
|
||||
WHERE id IN (SELECT label_id
|
||||
FROM cpt_task_labels ctl
|
||||
WHERE task_id IN (SELECT id
|
||||
FROM cpt_tasks
|
||||
WHERE cpt_tasks.template_id = pt.id))) rec) AS labels,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name,
|
||||
color_code
|
||||
FROM task_priorities) rec) AS priorities,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT id AS original_task_id,
|
||||
name,
|
||||
parent_task_id,
|
||||
description,
|
||||
total_minutes,
|
||||
(SELECT name FROM cpt_task_statuses cts WHERE status_id = cts.id) AS status_name,
|
||||
(SELECT name FROM task_priorities tp WHERE priority_id = tp.id) AS priority_name,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name
|
||||
FROM cpt_phases pl
|
||||
WHERE pl.id =
|
||||
(SELECT phase_id FROM cpt_task_phases WHERE task_id = cpt_tasks.id)) rec) AS phases,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT name
|
||||
FROM team_labels pl
|
||||
LEFT JOIN cpt_task_labels cttl ON pl.id = cttl.label_id
|
||||
WHERE cttl.task_id = cpt_tasks.id) rec) AS labels
|
||||
FROM cpt_tasks
|
||||
WHERE template_id = pt.id
|
||||
ORDER BY parent_task_id NULLS FIRST) rec) AS tasks
|
||||
FROM custom_project_templates pt
|
||||
WHERE id = $1;`;
|
||||
const result = await db.query(q, [template_id]);
|
||||
const [data] = result.rows;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static async getAllKeysByTeamId(teamId?: string) {
|
||||
if (!teamId) return [];
|
||||
try {
|
||||
const result = await db.query("SELECT key FROM projects WHERE team_id = $1;", [teamId]);
|
||||
return result.rows.map((project: any) => project.key).filter((key: any) => !!key);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async checkProjectNameExists(project_name: string, teamId?: string) {
|
||||
if (!teamId) return;
|
||||
try {
|
||||
const result = await db.query("SELECT count(*) FROM projects WHERE name = $1 AND team_id = $2;", [project_name, teamId]);
|
||||
const [data] = result.rows;
|
||||
return int(data.count) || 0;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async importTemplate(body: any) {
|
||||
const q = `SELECT create_project($1) AS project`;
|
||||
|
||||
const count = await this.checkProjectNameExists(body.name, body.team_id);
|
||||
|
||||
const keys = await this.getAllKeysByTeamId(body.team_id as string);
|
||||
body.key = generateProjectKey(body.name, keys) || null;
|
||||
|
||||
if (count !== 0) body.name = `${body.name} - ${body.key}`;
|
||||
|
||||
const result = await db.query(q, [JSON.stringify(body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return data.project.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTeamLabels(labels: IProjectTemplateLabel[], team_id = "") {
|
||||
if (!team_id) return;
|
||||
|
||||
for await (const label of labels) {
|
||||
const q = `INSERT INTO team_labels(name, color_code, team_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (name, team_id) DO NOTHING;`;
|
||||
await db.query(q, [label.name, label.color_code, team_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertProjectPhases(phases: IProjectTemplatePhase[], project_id = "",) {
|
||||
if (!project_id) return;
|
||||
|
||||
let i = 0;
|
||||
|
||||
for await (const phase of phases) {
|
||||
const q = `INSERT INTO project_phases(name, color_code, project_id, sort_index) VALUES ($1, $2, $3, $4);`;
|
||||
await db.query(q, [phase.name, phase.color_code, project_id, i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
protected static async insertProjectStatuses(statuses: IProjectTemplateStatus[], project_id = "", team_id = "") {
|
||||
if (!project_id || !team_id) return;
|
||||
|
||||
try {
|
||||
for await (const status of statuses) {
|
||||
const q = `INSERT INTO task_statuses(name, project_id, team_id, category_id) VALUES($1, $2, $3, $4);`;
|
||||
await db.query(q, [status.name, project_id, team_id, status.category_id]);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTaskPhase(task_id: string, phase_name: string, project_id: string) {
|
||||
const q = `INSERT INTO task_phase(task_id, phase_id)
|
||||
VALUES ($1, (SELECT id FROM project_phases WHERE name = $2 AND project_id = $3));`;
|
||||
await db.query(q, [task_id, phase_name, project_id]);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertTaskLabel(task_id: string, label_name: string, team_id: string) {
|
||||
const q = `INSERT INTO task_labels(task_id, label_id)
|
||||
VALUES ($1, (SELECT id FROM team_labels WHERE name = $2 AND team_id = $3));`;
|
||||
await db.query(q, [task_id, label_name, team_id]);
|
||||
}
|
||||
|
||||
protected static async insertProjectTasks(tasks: IProjectTemplateTask[], team_id: string, project_id = "", user_id = "", socket: Socket | null) {
|
||||
if (!project_id) return;
|
||||
|
||||
try {
|
||||
for await (const [key, task] of tasks.entries()) {
|
||||
const q = `INSERT INTO tasks(name, project_id, status_id, priority_id, reporter_id, sort_order)
|
||||
VALUES ($1, $2, (SELECT id FROM task_statuses ts WHERE ts.name = $3 AND ts.project_id = $2),
|
||||
(SELECT id FROM task_priorities tp WHERE tp.name = $4), $5, $6)
|
||||
RETURNING id, status_id;`;
|
||||
const result = await db.query(q, [task.name, project_id, task.status_name, task.priority_name, user_id, key]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (task.phases) {
|
||||
for await (const phase of task.phases) {
|
||||
await this.insertTaskPhase(data.id, phase.name as string, project_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (task.labels) {
|
||||
for await (const label of task.labels) {
|
||||
await this.insertTaskLabel(data.id, label.name as string, team_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
logStatusChange({
|
||||
task_id: data.id,
|
||||
socket,
|
||||
new_value: data.status_id,
|
||||
old_value: null
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// custom templates
|
||||
@HandleExceptions()
|
||||
protected static async getProjectData(project_id: string) {
|
||||
const q = `SELECT phase_label, notes, color_code FROM projects WHERE id = $1;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
const [data] = result.rows;
|
||||
return data;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getProjectStatus(project_id: string) {
|
||||
const q = `SELECT name, category_id, sort_order FROM task_statuses WHERE project_id = $1;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getProjectPhases(project_id: string) {
|
||||
const q = `SELECT name, color_code FROM project_phases WHERE project_id = $1 ORDER BY sort_index ASC;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getProjectLabels(team_id: string, project_id: string) {
|
||||
const q = `SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(DISTINCT JSONB_BUILD_OBJECT('name', name))), '[]'::JSON) AS labels
|
||||
FROM team_labels
|
||||
WHERE team_id = $1
|
||||
AND id IN (SELECT label_id
|
||||
FROM task_labels
|
||||
WHERE task_id IN (SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = $2));`;
|
||||
const result = await db.query(q, [team_id, project_id]);
|
||||
const [data] = result.rows;
|
||||
return data.labels;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async getTasksByProject(project_id: string, taskIncludes: ITaskIncludes) {
|
||||
let taskIncludesClause = "";
|
||||
|
||||
if (taskIncludes.description) taskIncludesClause += " description,";
|
||||
if (taskIncludes.estimation) taskIncludesClause += " total_minutes,";
|
||||
if (taskIncludes.status) taskIncludesClause += ` (SELECT name FROM task_statuses WHERE status_id = id) AS status_name,`;
|
||||
if (taskIncludes.labels) {
|
||||
taskIncludesClause += ` (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT (SELECT name FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = t.id) rec) AS labels,`;
|
||||
}
|
||||
if (taskIncludes.phase) {
|
||||
taskIncludesClause += ` (SELECT name
|
||||
FROM project_phases
|
||||
WHERE project_phases.id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name,`;
|
||||
}
|
||||
if (taskIncludes.subtasks) {
|
||||
taskIncludesClause += ` parent_task_id,`;
|
||||
}
|
||||
|
||||
const q = `SELECT id,
|
||||
name,
|
||||
sort_order,
|
||||
task_no,
|
||||
${taskIncludesClause}
|
||||
priority_id
|
||||
FROM tasks t
|
||||
WHERE project_id = $1
|
||||
AND archived IS FALSE ORDER BY parent_task_id NULLS FIRST;`;
|
||||
const result = await db.query(q, [project_id]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplate(body: ICustomProjectTemplate) {
|
||||
const q = `SELECT create_project_template($1)`;
|
||||
const result = await db.query(q, [JSON.stringify(body)]);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplatePhases(body: ICustomTemplatePhase[], template_id: string) {
|
||||
for await (const phase of body) {
|
||||
const { name, color_code } = phase;
|
||||
|
||||
const q = `INSERT INTO cpt_phases(name, color_code, template_id) VALUES ($1, $2, $3);`;
|
||||
await db.query(q, [name, color_code, template_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplateStatus(body: IProjectTemplateStatus[], template_id: string, team_id: string) {
|
||||
for await (const status of body) {
|
||||
const { name, category_id, sort_order } = status;
|
||||
|
||||
const q = `INSERT INTO cpt_task_statuses(name, template_id, team_id, category_id, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5);`;
|
||||
await db.query(q, [name, template_id, team_id, category_id, sort_order]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplateTasks(body: IProjectTemplateTask[], template_id: string, team_id: string, status = true) {
|
||||
for await (const task of body) {
|
||||
const { name, description, total_minutes, sort_order, priority_id, status_name, task_no, parent_task_id, id, phase_name } = task;
|
||||
|
||||
const q = `INSERT INTO cpt_tasks(name, description, total_minutes, sort_order, priority_id, template_id, status_id, task_no,
|
||||
parent_task_id, original_task_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, (SELECT id FROM cpt_task_statuses cts WHERE cts.name = $7 AND cts.template_id = $6), $8,
|
||||
(SELECT id FROM cpt_tasks WHERE original_task_id = $9 AND template_id = $6), $10)
|
||||
RETURNING id;`;
|
||||
const result = await db.query(q, [name, description, total_minutes || 0, sort_order, priority_id, template_id, status_name, task_no, parent_task_id, id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data.id) {
|
||||
if (phase_name) await this.insertCustomTemplateTaskPhases(data.id, template_id, phase_name);
|
||||
if (task.labels) await this.insertCustomTemplateTaskLabels(data.id, task.labels, team_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplateTaskPhases(task_id: string, template_id: string, phase_name = "") {
|
||||
const q = `INSERT INTO cpt_task_phases (task_id, phase_id)
|
||||
VALUES ($1, (SELECT id FROM cpt_phases WHERE template_id = $2 AND name = $3));`;
|
||||
await db.query(q, [task_id, template_id, phase_name]);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async insertCustomTemplateTaskLabels(task_id: string, labels: IProjectTemplateLabel[], team_id: string) {
|
||||
for await (const label of labels) {
|
||||
const q = `INSERT INTO cpt_task_labels(task_id, label_id)
|
||||
VALUES ($1, (SELECT id FROM team_labels WHERE name = $2 AND team_id = $3));`;
|
||||
await db.query(q, [task_id, label.name, team_id]);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async updateTeamName(name: string, team_id: string, user_id: string) {
|
||||
const q = `UPDATE teams SET name = TRIM($1::TEXT) WHERE id = $2 AND user_id = $3;`;
|
||||
const result = await db.query(q, [name, team_id, user_id]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async deleteDefaultStatusForProject(task_id: string) {
|
||||
const q = `DELETE FROM task_statuses WHERE project_id = $1;`;
|
||||
await db.query(q, [task_id]);
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
protected static async handleAccountSetup(project_id: string, user_id: string, team_name: string) {
|
||||
// update user setup status
|
||||
await db.query(`UPDATE users SET setup_completed = TRUE WHERE id = $1;`, [user_id]);
|
||||
|
||||
await db.query(`INSERT INTO organizations (user_id, organization_name, contact_number, contact_number_secondary, trial_in_progress,
|
||||
trial_expire_date, subscription_status)
|
||||
VALUES ($1, TRIM($2::TEXT), NULL, NULL, TRUE, CURRENT_DATE + INTERVAL '14 days', 'trialing')
|
||||
ON CONFLICT (user_id) DO UPDATE SET organization_name = TRIM($2::TEXT);`, [user_id, team_name]);
|
||||
}
|
||||
|
||||
protected static async insertProjectTasksFromCustom(tasks: IProjectTemplateTask[], team_id: string, project_id = "", user_id = "", socket: Socket | null) {
|
||||
if (!project_id) return;
|
||||
|
||||
try {
|
||||
for await (const [key, task] of tasks.entries()) {
|
||||
const q = `INSERT INTO tasks(name, project_id, status_id, priority_id, reporter_id, sort_order, parent_task_id, description, total_minutes)
|
||||
VALUES ($1, $2, (SELECT id FROM task_statuses ts WHERE ts.name = $3 AND ts.project_id = $2),
|
||||
(SELECT id FROM task_priorities tp WHERE tp.name = $4), $5, $6, $7, $8, $9)
|
||||
RETURNING id, status_id;`;
|
||||
|
||||
const parent_task: IProjectTemplateTask = tasks.find(t => t.original_task_id === task.parent_task_id) || {};
|
||||
|
||||
const result = await db.query(q, [task.name, project_id, task.status_name, task.priority_name, user_id, key, parent_task.id, task.description, task.total_minutes ? task.total_minutes : 0]);
|
||||
const [data] = result.rows;
|
||||
task.id = data.id;
|
||||
|
||||
if (task.phases) {
|
||||
for await (const phase of task.phases) {
|
||||
await this.insertTaskPhase(data.id, phase.name as string, project_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (task.labels) {
|
||||
for await (const label of task.labels) {
|
||||
await this.insertTaskLabel(data.id, label.name as string, team_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
logStatusChange({
|
||||
task_id: data.id,
|
||||
socket,
|
||||
new_value: data.status_id,
|
||||
old_value: null
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
|
||||
export default class PtTaskPhasesController extends WorklenzControllerBase {
|
||||
|
||||
private static readonly DEFAULT_PHASE_COLOR = "#fbc84c";
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.query.id)
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid request"));
|
||||
|
||||
const q = `
|
||||
INSERT INTO cpt_phases (name, color_code, template_id)
|
||||
VALUES (CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM cpt_phases WHERE template_id = $2) + 1, ')'), $1,
|
||||
$2)
|
||||
RETURNING id, name, color_code;
|
||||
`;
|
||||
|
||||
req.body.color_code = this.DEFAULT_PHASE_COLOR;
|
||||
|
||||
const result = await db.query(q, [req.body.color_code, req.query.id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
data.color_code = data.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT id, name, color_code, (SELECT COUNT(*) FROM cpt_task_phases WHERE phase_id = cpt_phases.id) AS usage
|
||||
FROM cpt_phases
|
||||
WHERE template_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.query.id]);
|
||||
|
||||
for (const phase of result.rows)
|
||||
phase.color_code = phase.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"PHASE_EXISTS_ERROR": `Phase name "{0}" already exists. Please choose a different name.`
|
||||
}
|
||||
})
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT update_phase_name($1, $2, $3);`;
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.body.name.trim(), req.query.id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
data.update_phase_name.color_code = data.update_phase_name.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.update_phase_name));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
UPDATE custom_project_templates
|
||||
SET phase_label = $2
|
||||
WHERE id = $1;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.body.name.trim()]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateColor(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `UPDATE cpt_phases SET color_code = $3 WHERE id = $1 AND template_id = $2 RETURNING id, name, color_code;`;
|
||||
const result = await db.query(q, [req.params.id, req.query.id, req.body.color_code.substring(0, req.body.color_code.length - 2)]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE
|
||||
FROM cpt_phases
|
||||
WHERE id = $1
|
||||
AND template_id = $2
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.query.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
|
||||
const existsErrorMessage = "At least one status should exists under each category.";
|
||||
|
||||
export default class PtTaskStatusesController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO cpt_task_statuses (name, template_id, team_id, category_id, sort_order)
|
||||
VALUES ($1, $2, $3, $4, (SELECT MAX(sort_order) FROM cpt_task_statuses WHERE template_id = $2) + 1);
|
||||
`;
|
||||
const result = await db.query(q, [req.body.name, req.body.template_id, req.user?.team_id, req.body.category_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCreated(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const team_id = req.user?.team_id;
|
||||
const q = `SELECT create_pt_task_status($1, $2)`;
|
||||
const result = await db.query(q, [JSON.stringify(req.body), team_id]);
|
||||
const data = result.rows[0].create_pt_task_status[0];
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.query.template_id)
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
|
||||
const q = `
|
||||
SELECT cpt_task_statuses.id,
|
||||
cpt_task_statuses.name,
|
||||
stsc.color_code,
|
||||
stsc.name AS category_name,
|
||||
cpt_task_statuses.category_id,
|
||||
stsc.description
|
||||
FROM cpt_task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON cpt_task_statuses.category_id = stsc.id
|
||||
WHERE template_id = $1
|
||||
AND team_id = $2
|
||||
ORDER BY cpt_task_statuses.sort_order;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.query.template_id, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCategories(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, color_code, description
|
||||
FROM sys_task_status_categories
|
||||
ORDER BY index;`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT cpt_task_statuses.id, cpt_task_statuses.name, stsc.color_code
|
||||
FROM cpt_task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON cpt_task_statuses.category_id = stsc.id
|
||||
WHERE cpt_task_statuses.id = $1
|
||||
AND template_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.query.template_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
private static async hasMoreCategories(statusId: string, templateId: string) {
|
||||
if (!statusId || !templateId)
|
||||
return false;
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(*) AS count
|
||||
FROM cpt_task_statuses
|
||||
WHERE category_id = (SELECT category_id FROM cpt_task_statuses WHERE id = $1)
|
||||
AND template_id = $2;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [statusId, templateId]);
|
||||
const [data] = result.rows;
|
||||
return +data.count >= 2;
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const hasMoreCategories = await PtTaskStatusesController.hasMoreCategories(req.params.id, req.body.template_id);
|
||||
|
||||
if (!hasMoreCategories)
|
||||
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status update failed!"));
|
||||
|
||||
const q = `
|
||||
UPDATE cpt_task_statuses
|
||||
SET name = $2,
|
||||
category_id = COALESCE($4, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE))
|
||||
WHERE id = $1
|
||||
AND template_id = $3
|
||||
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = cpt_task_statuses.category_id);
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.body.name, req.body.template_id, req.body.category_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"STATUS_EXISTS_ERROR": `Status name "{0}" already exists. Please choose a different name.`
|
||||
}
|
||||
})
|
||||
public static async updateName(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DO
|
||||
$$
|
||||
BEGIN
|
||||
-- check whether the status name is already in
|
||||
IF EXISTS(SELECT name
|
||||
FROM cpt_task_statuses
|
||||
WHERE name = '${req.body.name}'::TEXT
|
||||
AND template_id = '${req.body.template_id}'::UUID)
|
||||
THEN
|
||||
RAISE 'STATUS_EXISTS_ERROR:%', ('${req.body.name}')::TEXT;
|
||||
END IF;
|
||||
|
||||
UPDATE cpt_task_statuses
|
||||
SET name = '${req.body.name}'::TEXT
|
||||
WHERE id = '${req.params.id}'::UUID
|
||||
AND template_id = '${req.body.template_id}'::UUID;
|
||||
END
|
||||
$$;`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
|
||||
export const GroupBy = {
|
||||
STATUS: "status",
|
||||
PRIORITY: "priority",
|
||||
LABELS: "labels",
|
||||
PHASE: "phase"
|
||||
};
|
||||
|
||||
export interface ITaskGroup {
|
||||
id?: string;
|
||||
name: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
color_code: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
todo_progress?: number;
|
||||
doing_progress?: number;
|
||||
done_progress?: number;
|
||||
tasks: any[];
|
||||
}
|
||||
|
||||
|
||||
export default class PtTasksControllerBase extends WorklenzControllerBase {
|
||||
|
||||
public static updateTaskViewModel(task: any) {
|
||||
|
||||
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
|
||||
|
||||
if (typeof task.sub_tasks_count === "undefined") task.sub_tasks_count = "0";
|
||||
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
|
||||
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
task.show_sub_tasks = false;
|
||||
|
||||
if (task.phase_id) {
|
||||
task.phase_color = task.phase_color
|
||||
? task.phase_color + TASK_PRIORITY_COLOR_ALPHA : getColor(task.phase_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
task.all_labels = task.labels;
|
||||
task.labels = PtTasksControllerBase.createTagList(task.labels, 2);
|
||||
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { ParsedQs } from "qs";
|
||||
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import PtTasksControllerBase, { GroupBy, ITaskGroup } from "./pt-tasks-controller-base";
|
||||
|
||||
export class PtTaskListGroup implements ITaskGroup {
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
color_code: string;
|
||||
tasks: any[];
|
||||
|
||||
constructor(group: any) {
|
||||
this.name = group.name;
|
||||
this.category_id = group.category_id || null;
|
||||
this.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
this.tasks = [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class PtTasksController extends PtTasksControllerBase {
|
||||
private static isCountsOnly(query: ParsedQs) {
|
||||
return query.count === "true";
|
||||
}
|
||||
|
||||
public static isTasksOnlyReq(query: ParsedQs) {
|
||||
return PtTasksController.isCountsOnly(query) || query.parent_task;
|
||||
}
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(" ").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
private static getFilterByTemplatsWhereClosure(text: string) {
|
||||
return text ? `template_id IN (${this.flatString(text)})` : "";
|
||||
}
|
||||
|
||||
private static getQuery(userId: string, options: ParsedQs) {
|
||||
|
||||
const searchField = options.search ? "cptt.name" : "sort_order";
|
||||
const { searchQuery, sortField } = PtTasksController.toPaginationOptions(options, searchField);
|
||||
|
||||
const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order";
|
||||
|
||||
const isSubTasks = !!options.parent_task;
|
||||
|
||||
const subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL";
|
||||
|
||||
return `
|
||||
SELECT id,
|
||||
name,
|
||||
cptt.template_id AS template_id,
|
||||
cptt.parent_task_id,
|
||||
cptt.parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT(*)
|
||||
FROM cpt_tasks
|
||||
WHERE parent_task_id = cptt.id)::INT AS sub_tasks_count,
|
||||
cptt.status_id AS status,
|
||||
cptt.description,
|
||||
cptt.sort_order,
|
||||
(SELECT phase_id FROM cpt_task_phases WHERE task_id = cptt.id) AS phase_id,
|
||||
(SELECT name
|
||||
FROM cpt_phases
|
||||
WHERE id = (SELECT phase_id FROM cpt_task_phases WHERE task_id = cptt.id)) AS phase_name,
|
||||
(SELECT color_code
|
||||
FROM cpt_phases
|
||||
WHERE id = (SELECT phase_id FROM cpt_task_phases WHERE task_id = cptt.id)) AS phase_color,
|
||||
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM cpt_task_statuses WHERE id = cptt.status_id)) AS status_color,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM cpt_task_statuses WHERE id = cptt.status_id)) r) AS status_category,
|
||||
|
||||
(SELECT COALESCE(JSON_AGG(r), '[]'::JSON)
|
||||
FROM (SELECT cpt_task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = cpt_task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = cpt_task_labels.label_id)
|
||||
FROM cpt_task_labels
|
||||
WHERE task_id = cptt.id) r) AS labels,
|
||||
(SELECT id FROM task_priorities WHERE id = cptt.priority_id) AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = cptt.priority_id) AS priority_value,
|
||||
total_minutes
|
||||
FROM cpt_tasks cptt
|
||||
WHERE cptt.template_id=$1 AND ${subTasksFilter} ${searchQuery}
|
||||
ORDER BY ${sortFields}
|
||||
`;
|
||||
}
|
||||
|
||||
public static async getGroups(groupBy: string, templateId: string): Promise<ITaskGroup[]> {
|
||||
let q = "";
|
||||
let params: any[] = [];
|
||||
switch (groupBy) {
|
||||
case GroupBy.STATUS:
|
||||
q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = cpt_task_statuses.category_id),
|
||||
category_id
|
||||
FROM cpt_task_statuses
|
||||
WHERE template_id = $1
|
||||
ORDER BY sort_order;
|
||||
`;
|
||||
params = [templateId];
|
||||
break;
|
||||
case GroupBy.PRIORITY:
|
||||
q = `SELECT id, name, color_code
|
||||
FROM task_priorities
|
||||
ORDER BY value DESC;`;
|
||||
break;
|
||||
case GroupBy.LABELS:
|
||||
q = `
|
||||
SELECT id, name, color_code
|
||||
FROM team_labels
|
||||
WHERE team_id = $2
|
||||
AND EXISTS(SELECT 1
|
||||
FROM cpt_tasks
|
||||
WHERE template_id = $1
|
||||
AND EXISTS(SELECT 1 FROM cpt_task_labels WHERE task_id = cpt_tasks.id AND label_id = team_labels.id))
|
||||
ORDER BY name;
|
||||
`;
|
||||
break;
|
||||
case GroupBy.PHASE:
|
||||
q = `
|
||||
SELECT id, name, color_code
|
||||
FROM cpt_phases
|
||||
WHERE template_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
params = [templateId];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await db.query(q, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
|
||||
const q = PtTasksController.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const tasks = [...result.rows];
|
||||
|
||||
const groups = await this.getGroups(groupBy, req.params.id);
|
||||
const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => {
|
||||
if (group.id)
|
||||
g[group.id] = new PtTaskListGroup(group);
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
this.updateMapByGroup(tasks, groupBy, map);
|
||||
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
|
||||
let index = 0;
|
||||
const unmapped = [];
|
||||
for (const task of tasks) {
|
||||
task.index = index++;
|
||||
PtTasksController.updateTaskViewModel(task);
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
map[task.status]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PRIORITY) {
|
||||
map[task.priority]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PHASE && task.phase_id) {
|
||||
map[task.phase_id]?.tasks.push(task);
|
||||
} else {
|
||||
unmapped.push(task);
|
||||
}
|
||||
|
||||
const totalMinutes = task.total_minutes;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
task.total_hours = hours;
|
||||
task.total_minutes = minutes;
|
||||
}
|
||||
|
||||
if (unmapped.length) {
|
||||
map[UNMAPPED] = {
|
||||
name: UNMAPPED,
|
||||
category_id: null,
|
||||
color_code: "#fbc84c69",
|
||||
tasks: unmapped
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const q = PtTasksController.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
const result = await db.query(q, params);
|
||||
|
||||
let data: any[] = [];
|
||||
|
||||
// if true, we only return the record count
|
||||
if (this.isCountsOnly(req.query)) {
|
||||
[data] = result.rows;
|
||||
} else { // else we return a flat list of tasks
|
||||
data = [...result.rows];
|
||||
for (const task of data) {
|
||||
PtTasksController.updateTaskViewModel(task);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkDelete(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const deletedTasks = req.body.tasks.map((t: any) => t.id);
|
||||
|
||||
const result: any = {deleted_tasks: deletedTasks};
|
||||
|
||||
const q = `SELECT bulk_delete_pt_tasks($1) AS task;`;
|
||||
await db.query(q, [JSON.stringify(req.body)]);
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
|
||||
import db from "../../config/db";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { templateData } from "./project-templates";
|
||||
import ProjectTemplatesControllerBase from "./project-templates-base";
|
||||
import { LOG_DESCRIPTIONS, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { IO } from "../../shared/io";
|
||||
|
||||
export default class ProjectTemplatesController extends ProjectTemplatesControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name FROM pt_project_templates ORDER BY name;`;
|
||||
const result = await db.query(q, []);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCustomTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery } = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `SELECT id, name, created_at, FALSE AS selected FROM custom_project_templates WHERE team_id = $1 ${searchQuery} ORDER BY name;`;
|
||||
const result = await db.query(q, [req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteCustomTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
|
||||
const q = `DELETE FROM custom_project_templates WHERE id = $1;`;
|
||||
await db.query(q, [id]);
|
||||
return res.status(200).send(new ServerResponse(true, [], "Template deleted successfully."));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getDefaultProjectStatus() {
|
||||
const q = `SELECT id FROM sys_project_statuses WHERE is_default IS TRUE;`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getDefaultProjectHealth() {
|
||||
const q = `SELECT id FROM sys_project_healths WHERE is_default IS TRUE`;
|
||||
const result = await db.query(q, []);
|
||||
const [data] = result.rows;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTemplateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const data = await this.getTemplateData(id);
|
||||
|
||||
for (const phase of data.phases) {
|
||||
phase.color_code = phase.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
for (const status of data.status) {
|
||||
status.color_code = status.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
for (const priority of data.priorities) {
|
||||
priority.color_code = priority.color_code + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
for (const label of data.labels) {
|
||||
label.color_code = label.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
for (const template of templateData) {
|
||||
let template_id: string | null = null;
|
||||
template_id = await this.insertProjectTemplate(template);
|
||||
if (template_id) {
|
||||
await this.insertTemplateProjectPhases(template.phases, template_id);
|
||||
await this.insertTemplateProjectStatuses(template.status, template_id);
|
||||
await this.insertTemplateProjectTasks(template.tasks, template_id);
|
||||
}
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async importTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { template_id } = req.body;
|
||||
let project_id: string | null = null;
|
||||
|
||||
const data = await this.getTemplateData(template_id);
|
||||
if (data) {
|
||||
data.team_id = req.user?.team_id || null;
|
||||
data.user_id = req.user?.id || null;
|
||||
data.folder_id = null;
|
||||
data.category_id = null;
|
||||
data.status_id = await this.getDefaultProjectStatus();
|
||||
data.project_created_log = LOG_DESCRIPTIONS.PROJECT_CREATED;
|
||||
data.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED;
|
||||
data.health_id = await this.getDefaultProjectHealth();
|
||||
data.working_days = 0;
|
||||
data.man_days = 0;
|
||||
data.hours_per_day = 8;
|
||||
|
||||
project_id = await this.importTemplate(data);
|
||||
|
||||
await this.insertTeamLabels(data.labels, req.user?.team_id);
|
||||
await this.insertProjectPhases(data.phases, project_id as string);
|
||||
await this.insertProjectTasks(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { project_id }));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, { project_id }));
|
||||
}
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"TEMPLATE_EXISTS_ERROR": `A template with the name "{0}" already exists. Please choose a different name.`
|
||||
}
|
||||
})
|
||||
public static async createCustomTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, templateName, projectIncludes, taskIncludes } = req.body;
|
||||
const team_id = req.user?.team_id || null;
|
||||
|
||||
if (!team_id || !project_id) return res.status(400).send(new ServerResponse(false, {}));
|
||||
|
||||
|
||||
let status, labels, phases = [];
|
||||
|
||||
const data = await this.getProjectData(project_id);
|
||||
|
||||
if (projectIncludes.statuses) {
|
||||
status = await this.getProjectStatus(project_id);
|
||||
}
|
||||
if (projectIncludes.phases) {
|
||||
phases = await this.getProjectPhases(project_id);
|
||||
}
|
||||
if (projectIncludes.labels) {
|
||||
labels = await this.getProjectLabels(team_id, project_id);
|
||||
}
|
||||
|
||||
const tasks = await this.getTasksByProject(project_id, taskIncludes);
|
||||
|
||||
data.name = templateName;
|
||||
data.team_id = team_id;
|
||||
|
||||
const q = `SELECT create_project_template($1);`;
|
||||
const result = await db.query(q, [JSON.stringify(data)]);
|
||||
const [obj] = result.rows;
|
||||
|
||||
const template_id = obj.create_project_template.id;
|
||||
|
||||
if (template_id) {
|
||||
if (phases) await this.insertCustomTemplatePhases(phases, template_id);
|
||||
if (status) await this.insertCustomTemplateStatus(status, template_id, team_id);
|
||||
if (tasks) await this.insertCustomTemplateTasks(tasks, template_id, team_id);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {}, "Project template created successfully."));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async setupAccount(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { template_id, team_name } = req.body;
|
||||
let project_id: string | null = null;
|
||||
|
||||
await this.updateTeamName(team_name, req.user?.team_id as string, req.user?.id as string);
|
||||
|
||||
const data = await this.getTemplateData(template_id);
|
||||
if (data) {
|
||||
data.team_id = req.user?.team_id || null;
|
||||
data.user_id = req.user?.id || null;
|
||||
data.folder_id = null;
|
||||
data.category_id = null;
|
||||
data.status_id = await this.getDefaultProjectStatus();
|
||||
data.project_created_log = LOG_DESCRIPTIONS.PROJECT_CREATED;
|
||||
data.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED;
|
||||
data.health_id = await this.getDefaultProjectHealth();
|
||||
data.working_days = 0;
|
||||
data.man_days = 0;
|
||||
data.hours_per_day = 8;
|
||||
|
||||
project_id = await this.importTemplate(data);
|
||||
|
||||
await this.insertTeamLabels(data.labels, req.user?.team_id);
|
||||
await this.insertProjectPhases(data.phases, project_id as string);
|
||||
await this.insertProjectTasks(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
|
||||
|
||||
await this.handleAccountSetup(project_id as string, data.user_id, team_name);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { id: project_id }));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, { id: project_id }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async importCustomTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { template_id } = req.body;
|
||||
let project_id: string | null = null;
|
||||
|
||||
const data = await this.getCustomTemplateData(template_id);
|
||||
if (data) {
|
||||
data.team_id = req.user?.team_id || null;
|
||||
data.user_id = req.user?.id || null;
|
||||
data.folder_id = null;
|
||||
data.category_id = null;
|
||||
data.status_id = await this.getDefaultProjectStatus();
|
||||
data.project_created_log = LOG_DESCRIPTIONS.PROJECT_CREATED;
|
||||
data.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED;
|
||||
data.working_days = 0;
|
||||
data.man_days = 0;
|
||||
data.hours_per_day = 8;
|
||||
|
||||
project_id = await this.importTemplate(data);
|
||||
|
||||
await this.deleteDefaultStatusForProject(project_id as string);
|
||||
await this.insertTeamLabels(data.labels, req.user?.team_id);
|
||||
await this.insertProjectPhases(data.phases, project_id as string);
|
||||
await this.insertProjectStatuses(data.status, project_id as string, data.team_id );
|
||||
await this.insertProjectTasksFromCustom(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { project_id }));
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, { project_id }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import WorklenzControllerBase from ".././worklenz-controller-base";
|
||||
|
||||
export const GroupBy = {
|
||||
STATUS: "status",
|
||||
PRIORITY: "priority",
|
||||
LABELS: "labels",
|
||||
PHASE: "phase"
|
||||
};
|
||||
|
||||
export interface IWLTaskGroup {
|
||||
id?: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
tasks: any[];
|
||||
isExpand: boolean;
|
||||
}
|
||||
|
||||
export default class WLTasksControllerBase extends WorklenzControllerBase {
|
||||
protected static calculateTaskCompleteRatio(totalCompleted: number, totalTasks: number) {
|
||||
if (totalCompleted === 0 && totalTasks === 0) return 0;
|
||||
const ratio = ((totalCompleted / totalTasks) * 100);
|
||||
return ratio == Infinity ? 100 : ratio.toFixed();
|
||||
}
|
||||
|
||||
public static updateTaskViewModel(task: any) {
|
||||
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
|
||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
||||
|
||||
if (typeof task.sub_tasks_count === "undefined") task.sub_tasks_count = "0";
|
||||
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.name_color = getColor(task.name);
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
task.show_sub_tasks = false;
|
||||
|
||||
if (task.phase_id) {
|
||||
task.phase_color = task.phase_name
|
||||
? getColor(task.phase_name) + TASK_PRIORITY_COLOR_ALPHA
|
||||
: null;
|
||||
}
|
||||
|
||||
if (Array.isArray(task.assignees)) {
|
||||
for (const assignee of task.assignees) {
|
||||
assignee.color_code = getColor(assignee.name);
|
||||
}
|
||||
}
|
||||
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
const totalCompleted = +task.completed_sub_tasks + +task.parent_task_completed;
|
||||
const totalTasks = +task.sub_tasks_count + 1; // +1 for parent
|
||||
task.complete_ratio = WLTasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
|
||||
task.completed_count = totalCompleted;
|
||||
task.total_tasks_count = totalTasks;
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
import moment, { Moment } from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
import { ParsedQs } from "qs";
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import WLTasksControllerBase, { GroupBy, IWLTaskGroup } from "./workload-gannt-base";
|
||||
|
||||
interface IWorkloadTask {
|
||||
id: string;
|
||||
name: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
width: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export class IWLTaskListGroup implements IWLTaskGroup {
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
color_code: string;
|
||||
tasks: any[];
|
||||
isExpand: boolean;
|
||||
|
||||
constructor(group: any) {
|
||||
this.name = group.name;
|
||||
this.category_id = group.category_id || null;
|
||||
this.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
this.tasks = [];
|
||||
this.isExpand = group.isExpand;
|
||||
}
|
||||
}
|
||||
|
||||
export default class WorkloadGanntController extends WLTasksControllerBase {
|
||||
|
||||
private static GLOBAL_DATE_WIDTH = 30;
|
||||
private static GLOBAL_START_DATE = moment().format("YYYY-MM-DD");
|
||||
private static GLOBAL_END_DATE = moment().format("YYYY-MM-DD");
|
||||
|
||||
private static TASKS_START_DATE_NULL_FILTER = "start_date_null";
|
||||
private static TASKS_END_DATE_NULL_FILTER = "end_date_null";
|
||||
private static TASKS_START_END_DATES_NULL_FILTER = "start_end_dates_null";
|
||||
|
||||
private static async getFirstLastDates(projectId: string) {
|
||||
|
||||
const q = `SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(start_date) AS min_date, MAX(start_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1 AND tasks.archived IS FALSE
|
||||
UNION
|
||||
SELECT MIN(end_date) AS min_date, MAX(end_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1 AND tasks.archived IS FALSE) AS date_union;`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
private static async getLogsFirstLastDates(projectId: string) {
|
||||
const q = `SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS min_date,
|
||||
MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS max_date
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
|
||||
WHERE t.project_id = $1`;
|
||||
const res = await db.query(q, [projectId]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
private static validateEndDate(endDate: Moment): boolean {
|
||||
return endDate.isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static validateStartDate(startDate: Moment): boolean {
|
||||
return startDate.isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static getScrollAmount(startDate: Moment) {
|
||||
const today = moment();
|
||||
const daysDifference = today.diff(startDate, "days");
|
||||
|
||||
return (this.GLOBAL_DATE_WIDTH * daysDifference);
|
||||
}
|
||||
|
||||
private static setTaskCss(task: IWorkloadTask) {
|
||||
let startDate = task.start_date ? moment(task.start_date) : moment();
|
||||
let endDate = task.end_date ? moment(task.end_date) : moment();
|
||||
|
||||
if (!task.start_date) {
|
||||
startDate = moment(task.end_date);
|
||||
}
|
||||
if (!task.end_date) {
|
||||
endDate = moment(task.start_date);
|
||||
}
|
||||
if (!task.start_date && !task.end_date) {
|
||||
startDate = moment();
|
||||
endDate = moment();
|
||||
}
|
||||
|
||||
const daysDifferenceFromStart = startDate.diff(this.GLOBAL_START_DATE, "days");
|
||||
task.left = daysDifferenceFromStart * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
if (moment(startDate).isSame(moment(endDate), "day")) {
|
||||
task.width = this.GLOBAL_DATE_WIDTH;
|
||||
} else {
|
||||
const taskWidth = endDate.diff(startDate, "days");
|
||||
task.width = (taskWidth + 1) * this.GLOBAL_DATE_WIDTH;
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
private static setIndicator(startDate: string, endDate: string) {
|
||||
const daysFromStart = moment(startDate).diff(this.GLOBAL_START_DATE, "days");
|
||||
const indicatorOffset = daysFromStart * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
const daysDifference = moment(endDate).diff(startDate, "days");
|
||||
const indicatorWidth = (daysDifference + 1) * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
const body = {
|
||||
indicatorOffset,
|
||||
indicatorWidth
|
||||
};
|
||||
|
||||
return body;
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createDateRange(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const dateRange = await this.getFirstLastDates(req.params.id as string);
|
||||
const logRange = await this.getLogsFirstLastDates(req.params.id as string);
|
||||
|
||||
const today = new Date();
|
||||
|
||||
let startDate = moment(today).clone().startOf("month");
|
||||
let endDate = moment(today).clone().endOf("month");
|
||||
|
||||
this.setChartStartEnd(dateRange, logRange, req.query.timeZone as string);
|
||||
|
||||
if (dateRange.start_date && dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month");
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month");
|
||||
} else if (dateRange.start_date && !dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month");
|
||||
} else if (!dateRange.start_date && dateRange.end_date) {
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month");
|
||||
}
|
||||
|
||||
const xMonthsBeforeStart = startDate.clone().subtract(1, "months");
|
||||
const xMonthsAfterEnd = endDate.clone().add(1, "months");
|
||||
|
||||
this.GLOBAL_START_DATE = moment(xMonthsBeforeStart).format("YYYY-MM-DD");
|
||||
this.GLOBAL_END_DATE = moment(xMonthsAfterEnd).format("YYYY-MM-DD");
|
||||
|
||||
const dateData = [];
|
||||
let days = -1;
|
||||
|
||||
const currentDate = xMonthsBeforeStart.clone();
|
||||
|
||||
while (currentDate.isBefore(xMonthsAfterEnd)) {
|
||||
const monthData = {
|
||||
month: currentDate.format("MMM YYYY"),
|
||||
weeks: [] as number[],
|
||||
days: [] as { day: number, name: string, isWeekend: boolean, isToday: boolean }[],
|
||||
};
|
||||
const daysInMonth = currentDate.daysInMonth();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayOfMonth = currentDate.date();
|
||||
const dayName = currentDate.format("ddd");
|
||||
const isWeekend = [0, 6].includes(currentDate.day());
|
||||
const isToday = moment(moment(today).format("YYYY-MM-DD")).isSame(moment(currentDate).format("YYYY-MM-DD"));
|
||||
monthData.days.push({ day: dayOfMonth, name: dayName, isWeekend, isToday });
|
||||
currentDate.add(1, "day");
|
||||
days++;
|
||||
}
|
||||
dateData.push(monthData);
|
||||
}
|
||||
|
||||
const scrollBy = this.getScrollAmount(xMonthsBeforeStart);
|
||||
|
||||
const result = {
|
||||
date_data: dateData,
|
||||
width: days + 1,
|
||||
scroll_by: scrollBy,
|
||||
chart_start: moment(this.GLOBAL_START_DATE).format("YYYY-MM-DD"),
|
||||
chart_end: moment(this.GLOBAL_END_DATE).format("YYYY-MM-DD")
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
private static async setChartStartEnd(dateRange: any, logsRange: any, timeZone: string) {
|
||||
|
||||
if (dateRange.start_date)
|
||||
dateRange.start_date = momentTime.tz(dateRange.start_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (dateRange.end_date)
|
||||
dateRange.end_date = momentTime.tz(dateRange.end_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (logsRange.min_date)
|
||||
logsRange.min_date = momentTime.tz(logsRange.min_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (logsRange.max_date)
|
||||
logsRange.max_date = momentTime.tz(logsRange.max_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (moment(logsRange.min_date ).isBefore(dateRange.start_date))
|
||||
dateRange.start_date = logsRange.min_date;
|
||||
|
||||
if (moment(logsRange.max_date ).isAfter(dateRange.endDate))
|
||||
dateRange.end_date = logsRange.max_date;
|
||||
|
||||
return dateRange;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const expandedMembers: string[] = req.body.expanded_members;
|
||||
|
||||
const q = `SELECT pm.id AS project_member_id,
|
||||
tmiv.team_member_id,
|
||||
tmiv.user_id,
|
||||
name AS name,
|
||||
avatar_url,
|
||||
TRUE AS project_member,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(LEAST(start_date, end_date)) AS min_date,
|
||||
MAX(GREATEST(start_date, end_date)) AS max_date
|
||||
FROM tasks
|
||||
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = $1
|
||||
AND ta.team_member_id = tmiv.team_member_id) rec) AS duration,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS min_date,
|
||||
MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS max_date
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
|
||||
WHERE t.project_id = $1
|
||||
AND twl.user_id = tmiv.user_id) rec) AS logs_date_union,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT start_date,
|
||||
end_date
|
||||
FROM tasks
|
||||
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = pm.project_id
|
||||
AND ta.team_member_id = tmiv.team_member_id
|
||||
ORDER BY start_date ASC) rec) AS tasks
|
||||
FROM project_members pm
|
||||
INNER JOIN team_member_info_view tmiv ON pm.team_member_id = tmiv.team_member_id
|
||||
WHERE project_id = $1
|
||||
ORDER BY (SELECT MIN(LEAST(start_date, end_date))
|
||||
FROM tasks t
|
||||
INNER JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE t.archived IS FALSE
|
||||
AND t.project_id = $1
|
||||
AND ta.team_member_id = tmiv.team_member_id) ASC NULLS LAST`;
|
||||
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.color_code = getColor(member.TaskName);
|
||||
|
||||
this.setMaxMinDate(member, req.query.timeZone as string);
|
||||
|
||||
// if (member.duration[0].min_date)
|
||||
// member.duration[0].min_date = momentTime.tz(member.duration[0].min_date, `${req.query.timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
// if (member.duration[0].max_date)
|
||||
// member.duration[0].max_date = momentTime.tz(member.duration[0].max_date, `${req.query.timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
const fStartDate = member.duration.min_date ? moment(member.duration.min_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
const fEndDate = member.duration.max_date ? moment(member.duration.max_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
|
||||
if (member.tasks.length > 0) {
|
||||
const styles = this.setIndicator(fStartDate, fEndDate);
|
||||
member.indicator_offset = styles.indicatorOffset;
|
||||
member.indicator_width = styles.indicatorWidth;
|
||||
member.not_allocated = false;
|
||||
} else {
|
||||
member.indicator_offset = 0;
|
||||
member.indicator_width = 0;
|
||||
member.not_allocated = true;
|
||||
}
|
||||
|
||||
member.tasks_start_date = member.duration.min_date;
|
||||
member.tasks_end_date = member.duration.max_date;
|
||||
member.tasks_stats = await WorkloadGanntController.getMemberTasksStats(member.tasks);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
private static async setMaxMinDate(member: any, timeZone: string) {
|
||||
|
||||
if (member.duration.min_date)
|
||||
member.duration.min_date = momentTime.tz(member.duration.min_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (member.duration.max_date)
|
||||
member.duration.max_date = momentTime.tz(member.duration.max_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (member.duration.min_date && member.duration.max_date && member.logs_date_union.min_date && member.logs_date_union.max_date) {
|
||||
|
||||
const durationMin = momentTime.tz(member.duration.min_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
const durationMax = momentTime.tz(member.duration.max_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
const logMin = momentTime.tz(member.logs_date_union.min_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
const logMax = momentTime.tz(member.logs_date_union.max_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
if (moment(logMin).isBefore(durationMin)) {
|
||||
member.duration.min_date = logMin;
|
||||
}
|
||||
if (moment(logMax).isAfter(durationMax)) {
|
||||
member.duration.max_date = logMax;
|
||||
}
|
||||
|
||||
return member;
|
||||
|
||||
}
|
||||
|
||||
if (!member.duration.min_date && !member.duration.max_date && member.logs_date_union.min_date && member.logs_date_union.max_date) {
|
||||
|
||||
const logMin = momentTime.tz(member.logs_date_union.min_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
const logMax = momentTime.tz(member.logs_date_union.max_date, `${timeZone}`).format("YYYY-MM-DD");
|
||||
|
||||
member.duration.min_date = logMin;
|
||||
member.duration.max_date = logMax;
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
return member;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static async getMemberTasksStats(tasks: { start_date: string | null, end_date: string | null }[]) {
|
||||
const tasksCount = tasks.length;
|
||||
let nullStartCount = 0;
|
||||
let nullEndCount = 0;
|
||||
let nullBothCount = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
if ((!task.start_date || task.start_date.trim() === "") && (!task.end_date || task.end_date.trim() === "")) {
|
||||
nullBothCount++;
|
||||
} else if ((!task.start_date || task.start_date.trim() === "") && (task.end_date)) {
|
||||
nullStartCount++;
|
||||
} else if ((!task.end_date || task.end_date.trim() === "") && (task.start_date)) {
|
||||
nullEndCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
total: tasksCount,
|
||||
null_start_dates: nullStartCount,
|
||||
null_end_dates: nullEndCount,
|
||||
null_start_end_dates: nullBothCount,
|
||||
null_start_dates_percentage: (nullStartCount / tasksCount) * 100,
|
||||
null_end_dates_percentage: (nullEndCount / tasksCount) * 100,
|
||||
null_start_end_dates_percentage: (nullBothCount / tasksCount) * 100,
|
||||
available_start_end_dates_percentage: ((tasksCount - (nullStartCount + nullEndCount + nullBothCount)) / tasksCount) * 100
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
// ********************************************
|
||||
|
||||
private static isCountsOnly(query: ParsedQs) {
|
||||
return query.count === "true";
|
||||
}
|
||||
|
||||
public static isTasksOnlyReq(query: ParsedQs) {
|
||||
return WorkloadGanntController.isCountsOnly(query) || query.parent_task;
|
||||
}
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(" ").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
private static getFilterByDatesWhereClosure(text: string) {
|
||||
let closure = "";
|
||||
switch (text.trim()) {
|
||||
case "":
|
||||
closure = ``;
|
||||
break;
|
||||
case WorkloadGanntController.TASKS_START_DATE_NULL_FILTER:
|
||||
closure = `start_date IS NULL AND end_date IS NOT NULL`;
|
||||
break;
|
||||
case WorkloadGanntController.TASKS_END_DATE_NULL_FILTER:
|
||||
closure = `start_date IS NOT NULL AND end_date IS NULL`;
|
||||
break;
|
||||
case WorkloadGanntController.TASKS_START_END_DATES_NULL_FILTER:
|
||||
closure = `start_date IS NULL AND end_date IS NULL`;
|
||||
break;
|
||||
}
|
||||
return closure;
|
||||
}
|
||||
|
||||
private static getFilterByMembersWhereClosure(text: string) {
|
||||
return text
|
||||
? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString(text)}))`
|
||||
: "";
|
||||
}
|
||||
|
||||
private static getStatusesQuery(filterBy: string) {
|
||||
return filterBy === "member"
|
||||
? `, (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code
|
||||
FROM task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
|
||||
WHERE project_id = t.project_id
|
||||
ORDER BY task_statuses.name) rec) AS statuses`
|
||||
: "";
|
||||
}
|
||||
|
||||
public static async getTaskCompleteRatio(taskId: string): Promise<{
|
||||
ratio: number;
|
||||
total_completed: number;
|
||||
total_tasks: number;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||
const [data] = result.rows;
|
||||
data.info.ratio = +data.info.ratio.toFixed();
|
||||
return data.info;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static getQuery(userId: string, options: ParsedQs) {
|
||||
const searchField = options.search ? "t.name" : "sort_order";
|
||||
const { searchQuery, sortField } = WorkloadGanntController.toPaginationOptions(options, searchField);
|
||||
|
||||
const isSubTasks = !!options.parent_task;
|
||||
|
||||
const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order";
|
||||
// Filter tasks by its members
|
||||
const membersFilter = WorkloadGanntController.getFilterByMembersWhereClosure(options.members as string);
|
||||
// Returns statuses of each task as a json array if filterBy === "member"
|
||||
const statusesQuery = WorkloadGanntController.getStatusesQuery(options.filterBy as string);
|
||||
|
||||
const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE";
|
||||
|
||||
const datesFilter = WorkloadGanntController.getFilterByDatesWhereClosure(options.dateChecker as string);
|
||||
|
||||
|
||||
let subTasksFilter;
|
||||
|
||||
if (options.isSubtasksInclude === "true") {
|
||||
subTasksFilter = "";
|
||||
} else {
|
||||
subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL";
|
||||
}
|
||||
|
||||
const filters = [
|
||||
subTasksFilter,
|
||||
(isSubTasks ? "1 = 1" : archivedFilter),
|
||||
membersFilter,
|
||||
datesFilter
|
||||
].filter(i => !!i).join(" AND ");
|
||||
|
||||
return `
|
||||
SELECT id,
|
||||
name,
|
||||
t.project_id AS project_id,
|
||||
t.parent_task_id,
|
||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id)::INT AS sub_tasks_count,
|
||||
|
||||
t.status_id AS status,
|
||||
t.archived,
|
||||
t.sort_order,
|
||||
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||
(SELECT name
|
||||
FROM project_phases
|
||||
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name,
|
||||
|
||||
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category,
|
||||
|
||||
(CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END) AS parent_task_completed,
|
||||
(SELECT get_task_assignees(t.id)) AS assignees,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE tt.parent_task_id = t.id
|
||||
AND tt.is_done IS TRUE)::INT
|
||||
AS completed_sub_tasks,
|
||||
|
||||
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
total_minutes,
|
||||
start_date,
|
||||
end_date ${statusesQuery}
|
||||
FROM tasks t
|
||||
WHERE ${filters} ${searchQuery} AND project_id = $1
|
||||
ORDER BY end_date DESC NULLS LAST
|
||||
`;
|
||||
}
|
||||
|
||||
public static async getGroups(groupBy: string, projectId: string): Promise<IWLTaskGroup[]> {
|
||||
let q = "";
|
||||
let params: any[] = [];
|
||||
switch (groupBy) {
|
||||
case GroupBy.STATUS:
|
||||
q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id),
|
||||
category_id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
ORDER BY sort_order;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
case GroupBy.PRIORITY:
|
||||
q = `SELECT id, name, color_code
|
||||
FROM task_priorities
|
||||
ORDER BY value DESC;`;
|
||||
break;
|
||||
case GroupBy.LABELS:
|
||||
q = `
|
||||
SELECT id, name, color_code
|
||||
FROM team_labels
|
||||
WHERE team_id = $2
|
||||
AND EXISTS(SELECT 1
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND EXISTS(SELECT 1 FROM task_labels WHERE task_id = tasks.id AND label_id = team_labels.id))
|
||||
ORDER BY name;
|
||||
`;
|
||||
break;
|
||||
case GroupBy.PHASE:
|
||||
q = `
|
||||
SELECT id, name, color_code, start_date, end_date
|
||||
FROM project_phases
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await db.query(q, params);
|
||||
for (const row of result.rows) {
|
||||
row.isExpand = true;
|
||||
}
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
|
||||
const q = WorkloadGanntController.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const tasks = [...result.rows];
|
||||
|
||||
const groups = await this.getGroups(groupBy, req.params.id);
|
||||
const map = groups.reduce((g: { [x: string]: IWLTaskGroup }, group) => {
|
||||
if (group.id)
|
||||
g[group.id] = new IWLTaskListGroup(group);
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
this.updateMapByGroup(tasks, groupBy, map);
|
||||
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
|
||||
if (groupBy === GroupBy.PHASE)
|
||||
group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: IWLTaskGroup }) {
|
||||
let index = 0;
|
||||
const unmapped = [];
|
||||
for (const task of tasks) {
|
||||
task.index = index++;
|
||||
WorkloadGanntController.updateTaskViewModel(task);
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
map[task.status]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PRIORITY) {
|
||||
map[task.priority]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PHASE && task.phase_id) {
|
||||
map[task.phase_id]?.tasks.push(task);
|
||||
} else {
|
||||
unmapped.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmapped.length) {
|
||||
map[UNMAPPED] = {
|
||||
name: UNMAPPED,
|
||||
category_id: null,
|
||||
color_code: "#f0f0f0",
|
||||
tasks: unmapped,
|
||||
isExpand: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const q = WorkloadGanntController.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
const result = await db.query(q, params);
|
||||
|
||||
let data: any[] = [];
|
||||
|
||||
// if true, we only return the record count
|
||||
if (this.isCountsOnly(req.query)) {
|
||||
[data] = result.rows;
|
||||
} else { // else we return a flat list of tasks
|
||||
data = [...result.rows];
|
||||
for (const task of data) {
|
||||
WorkloadGanntController.updateTaskViewModel(task);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMemberOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const projectId = req.params.id;
|
||||
const teamMemberId = req.query.team_member_id;
|
||||
|
||||
const getCountByStatus = await WorkloadGanntController.getTasksCountsByStatus(projectId, teamMemberId as string);
|
||||
const getCountByPriority = await WorkloadGanntController.getTasksCountsByPriority(projectId, teamMemberId as string);
|
||||
const getCountByPhase = await WorkloadGanntController.getTasksCountsByPhase(projectId, teamMemberId as string);
|
||||
const getCountByDates = await WorkloadGanntController.getTasksCountsByDates(projectId, teamMemberId as string);
|
||||
const data = {
|
||||
by_status: getCountByStatus,
|
||||
by_priority: getCountByPriority,
|
||||
by_phase: getCountByPhase,
|
||||
by_dates: getCountByDates
|
||||
};
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
private static async getTasksCountsByStatus(projectId: string, teamMemberId: string) {
|
||||
const q = `SELECT ts.id,
|
||||
ts.name AS label,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = ts.category_id) AS color_code,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.project_id = $1
|
||||
AND t.archived IS FALSE
|
||||
AND t.id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND t.status_id = ts.id) rec) AS counts
|
||||
FROM task_statuses ts
|
||||
WHERE project_id = $1`;
|
||||
const res = await db.query(q, [projectId, teamMemberId]);
|
||||
for (const row of res.rows) {
|
||||
row.tasks_count = row.counts[0].count;
|
||||
}
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
private static async getTasksCountsByPriority(projectId: string, teamMemberId: string) {
|
||||
const q = `SELECT tp.id,
|
||||
tp.name AS label,
|
||||
tp.color_code,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT COUNT(*)
|
||||
FROM tasks t
|
||||
WHERE t.project_id = $1
|
||||
AND t.archived IS FALSE
|
||||
AND t.id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND t.priority_id = tp.id) rec) AS counts
|
||||
FROM task_priorities tp`;
|
||||
const res = await db.query(q, [projectId, teamMemberId]);
|
||||
for (const row of res.rows) {
|
||||
row.tasks_count = row.counts[0].count;
|
||||
}
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
private static async getTasksCountsByPhase(projectId: string, teamMemberId: string) {
|
||||
const q = `SELECT pp.id,
|
||||
pp.name AS label,
|
||||
pp.color_code AS color_code,
|
||||
COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) AS counts
|
||||
FROM project_phases pp
|
||||
LEFT JOIN (SELECT pp.id AS phase_id,
|
||||
COUNT(ta.task_id) AS task_count
|
||||
FROM project_phases pp
|
||||
LEFT JOIN task_phase tp ON pp.id = tp.phase_id
|
||||
LEFT JOIN tasks t ON tp.task_id = t.id
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id AND ta.team_member_id = $2
|
||||
WHERE pp.project_id = $1
|
||||
GROUP BY pp.id ) rec ON pp.id = rec.phase_id
|
||||
WHERE pp.project_id = $1
|
||||
GROUP BY pp.id`;
|
||||
const res = await db.query(q, [projectId, teamMemberId]);
|
||||
for (const row of res.rows) {
|
||||
row.tasks_count = row.counts[0].task_count;
|
||||
row.color_code = getColor(row.label) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
private static async getTasksCountsByDates(projectId: string, teamMemberId: string) {
|
||||
const q = `SELECT JSON_BUILD_OBJECT(
|
||||
'having_start_end_date', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND archived IS FALSE
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND end_date IS NOT NULL AND start_date IS NOT NULL),
|
||||
'no_end_date', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND archived IS FALSE
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND end_date IS NULL AND start_date IS NOT NULL),
|
||||
'no_start_date', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND archived IS FALSE
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND end_date IS NOT NULL AND start_date IS NULL),
|
||||
'no_start_end_dates', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND archived IS FALSE
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = $2)
|
||||
AND end_date IS NULL AND start_date IS NULL)) AS counts`;
|
||||
const res = await db.query(q, [projectId, teamMemberId]);
|
||||
const data = [
|
||||
{
|
||||
id: "",
|
||||
label: "Having start & end date",
|
||||
color_code: "#f0f0f0",
|
||||
tasks_count: res.rows[0].counts.having_start_end_date
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
label: "Without end date",
|
||||
color_code: "#F9A0A0BF",
|
||||
tasks_count: res.rows[0].counts.no_end_date
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
label: "Without start date",
|
||||
color_code: "#F8A9A98C",
|
||||
tasks_count: res.rows[0].counts.no_start_date
|
||||
},
|
||||
{
|
||||
id: "",
|
||||
label: "Without start & end date",
|
||||
color_code: "#F7A7A7E5",
|
||||
tasks_count: res.rows[0].counts.no_start_end_dates
|
||||
},
|
||||
];
|
||||
return data;
|
||||
}
|
||||
|
||||
// @HandleExceptions()
|
||||
// public static async getTasksByTeamMeberId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// const memberTasks = await this.getMemberTasks(req.params.id, req.body.team_member_id);
|
||||
// return res.status(200).send(new ServerResponse(true, memberTasks));
|
||||
// }
|
||||
|
||||
// private static async getMemberTasks(projectId: string, teamMemberId: string) {
|
||||
// const q = `
|
||||
// SELECT id AS task_id,
|
||||
// name AS task_name,
|
||||
// start_date AS start_date,
|
||||
// end_date AS end_date
|
||||
// FROM tasks
|
||||
// INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
|
||||
// WHERE archived IS FALSE
|
||||
// AND project_id = $1
|
||||
// AND ta.team_member_id = $2
|
||||
// ORDER BY start_date ASC`;
|
||||
// const result = await db.query(q, [projectId, teamMemberId]);
|
||||
|
||||
// for (const task of result.rows) {
|
||||
// this.setTaskCss(task);
|
||||
// }
|
||||
|
||||
// return result.rows;
|
||||
|
||||
// }
|
||||
|
||||
}
|
||||
701
worklenz-backend/src/controllers/projects-controller.ts
Normal file
701
worklenz-backend/src/controllers/projects-controller.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
import moment from "moment";
|
||||
import db from "../config/db";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {LOG_DESCRIPTIONS} from "../shared/constants";
|
||||
import {getColor} from "../shared/utils";
|
||||
import {generateProjectKey} from "../utils/generate-project-key";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||
import { IPassportSession } from "../interfaces/passport-session";
|
||||
import { SocketEvents } from "../socket.io/events";
|
||||
import { IO } from "../shared/io";
|
||||
|
||||
export default class ProjectsController extends WorklenzControllerBase {
|
||||
|
||||
private static async getAllKeysByTeamId(teamId?: string) {
|
||||
if (!teamId) return [];
|
||||
try {
|
||||
const result = await db.query("SELECT key FROM projects WHERE team_id = $1;", [teamId]);
|
||||
return result.rows.map((project: any) => project.key).filter((key: any) => !!key);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async notifyProjecManagertUpdates(projectId: string, user: IPassportSession, projectManagerTeamMemberId: string | null) {
|
||||
|
||||
if (projectManagerTeamMemberId) {
|
||||
const q = `SELECT (SELECT user_id FROM team_member_info_view WHERE team_member_id = $2) AS user_id,
|
||||
(SELECT socket_id FROM users WHERE id = (SELECT user_id FROM team_member_info_view WHERE team_member_id = $2)) AS socket_id,
|
||||
(SELECT name FROM projects WHERE id = $1) AS project_name
|
||||
FROM project_members pm WHERE project_id = $1
|
||||
AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')`;
|
||||
|
||||
const result = await db.query(q, [projectId, projectManagerTeamMemberId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (projectManagerTeamMemberId !== user.team_member_id) {
|
||||
void NotificationsService.createNotification({
|
||||
userId: data.user_id,
|
||||
teamId: user?.team_id as string,
|
||||
socketId: data.socket_id,
|
||||
message: `You're assigned as the <b> Project Manager </b> of the <b> ${data.project_name} </b>.`,
|
||||
taskId: null,
|
||||
projectId: projectId as string
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IO.getSocketById(user.socket_id as string)
|
||||
?.to(projectId)
|
||||
.emit(SocketEvents.PROJECT_DATA_CHANGE.toString(), {user_id: user.id});
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"PROJECT_EXISTS_ERROR": `A project with the name "{0}" already exists. Please choose a different name.`
|
||||
}
|
||||
})
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT create_project($1) AS project`;
|
||||
|
||||
req.body.team_id = req.user?.team_id || null;
|
||||
req.body.user_id = req.user?.id || null;
|
||||
|
||||
req.body.folder_id = req.body.folder_id || null;
|
||||
req.body.category_id = req.body.category_id?.trim() || null;
|
||||
req.body.client_name = req.body.client_name?.trim() || null;
|
||||
req.body.project_created_log = LOG_DESCRIPTIONS.PROJECT_CREATED;
|
||||
req.body.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED;
|
||||
req.body.project_manager_id = req.body.project_manager ? req.body.project_manager.id : null;
|
||||
|
||||
const keys = await this.getAllKeysByTeamId(req.user?.team_id as string);
|
||||
req.body.key = generateProjectKey(req.body.name, keys) || null;
|
||||
|
||||
const result = await db.query(q, [JSON.stringify(req.body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.project || {}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updatePinnedView(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.body.project_id;
|
||||
const teamMemberId = req.user?.team_member_id;
|
||||
const defaultView = req.body.default_view;
|
||||
|
||||
const q = `UPDATE project_members SET default_view = $1 WHERE project_id = $2 AND team_member_id = $3`;
|
||||
const result = await db.query(q, [defaultView, projectId, teamMemberId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMyProjectsToTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, color_code
|
||||
FROM projects
|
||||
WHERE team_id = $1
|
||||
AND is_member_of_project(projects.id, $2, $1)`;
|
||||
const result = await db.query(q, [req.user?.team_id, req.user?.id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMyProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS projects
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT id,
|
||||
name,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = projects.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = projects.id) AS archived,
|
||||
color_code,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = projects.id
|
||||
AND category_id IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_id = projects.id) AS members_count,
|
||||
(SELECT get_project_members(projects.id)) AS names,
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) >
|
||||
updated_at)
|
||||
THEN (SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id)
|
||||
ELSE updated_at END) AS updated_at
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery}
|
||||
AND is_member_of_project(projects.id
|
||||
, '${req.user?.id}'
|
||||
, $1)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery}
|
||||
AND is_member_of_project(projects.id
|
||||
, '${req.user?.id}'
|
||||
, $1)) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
const projects = Array.isArray(data?.projects.data) ? data?.projects.data : [];
|
||||
for (const project of projects) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
}
|
||||
return res.status(200).send(new ServerResponse(true, data?.projects || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(" ").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
private static getFilterByCategoryWhereClosure(text: string) {
|
||||
return text ? `AND category_id IN (${this.flatString(text)})` : "";
|
||||
}
|
||||
|
||||
private static getFilterByStatusWhereClosure(text: string) {
|
||||
return text ? `AND status_id IN (${this.flatString(text)})` : "";
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
||||
|
||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
||||
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS projects
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT id,
|
||||
name,
|
||||
(SELECT name FROM sys_project_statuses WHERE id = status_id) AS status,
|
||||
(SELECT color_code FROM sys_project_statuses WHERE id = status_id) AS status_color,
|
||||
(SELECT icon FROM sys_project_statuses WHERE id = status_id) AS status_icon,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = projects.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = projects.id) AS archived,
|
||||
color_code,
|
||||
start_date,
|
||||
end_date,
|
||||
category_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = projects.id
|
||||
AND category_id IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_id = projects.id) AS members_count,
|
||||
(SELECT get_project_members(projects.id)) AS names,
|
||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||
(SELECT name FROM users WHERE id = projects.owner_id) AS project_owner,
|
||||
(SELECT name FROM project_categories WHERE id = projects.category_id) AS category_name,
|
||||
(SELECT color_code
|
||||
FROM project_categories
|
||||
WHERE id = projects.category_id) AS category_color,
|
||||
|
||||
((SELECT team_member_id as team_member_id
|
||||
FROM project_members
|
||||
WHERE project_id = projects.id
|
||||
AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
||||
|
||||
(SELECT default_view
|
||||
FROM project_members prm
|
||||
WHERE prm.project_id = projects.id
|
||||
AND team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
||||
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id) >
|
||||
updated_at)
|
||||
THEN (SELECT MAX(updated_at)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = projects.id)
|
||||
ELSE updated_at END) AS updated_at
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const project of data?.projects.data || []) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
project.updated_at_string = moment(project.updated_at).fromNow();
|
||||
|
||||
project.names = this.createTagList(project?.names);
|
||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
if (project.project_manager_team_member_id) {
|
||||
project.project_manager = {
|
||||
id : project.project_manager_team_member_id
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.projects || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS members
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id = (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = project_members.team_member_id
|
||||
AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name
|
||||
FROM project_access_levels
|
||||
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4) t) AS data
|
||||
FROM project_members
|
||||
WHERE project_id = $1) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const member of data?.members.data || []) {
|
||||
member.progress = member.all_tasks_count > 0
|
||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT projects.id,
|
||||
projects.name,
|
||||
projects.color_code,
|
||||
projects.notes,
|
||||
projects.key,
|
||||
projects.start_date,
|
||||
projects.end_date,
|
||||
projects.status_id,
|
||||
projects.health_id,
|
||||
projects.created_at,
|
||||
projects.updated_at,
|
||||
projects.folder_id,
|
||||
projects.phase_label,
|
||||
projects.category_id,
|
||||
(projects.estimated_man_days) AS man_days,
|
||||
(projects.estimated_working_days) AS working_days,
|
||||
(projects.hours_per_day) AS hours_per_day,
|
||||
(SELECT name FROM project_categories WHERE id = projects.category_id) AS category_name,
|
||||
(SELECT color_code
|
||||
FROM project_categories
|
||||
WHERE id = projects.category_id) AS category_color,
|
||||
(EXISTS(SELECT 1 FROM project_subscribers WHERE project_id = $1 AND user_id = $3)) AS subscribed,
|
||||
(SELECT name FROM users WHERE id = projects.owner_id) AS project_owner,
|
||||
sps.name AS status,
|
||||
sps.color_code AS status_color,
|
||||
sps.icon AS status_icon,
|
||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||
FROM (SELECT team_member_id AS id,
|
||||
(SELECT COALESCE(ROW_TO_JSON(pmi), '{}'::JSON)
|
||||
FROM (SELECT name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_member_id = pm.team_member_id
|
||||
AND tmiv.team_id = (SELECT team_id FROM projects WHERE id = $1)) pmi) AS project_manager_info,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = pm.team_member_id
|
||||
AND email_invitations.team_id = (SELECT team_id
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_id = pm.team_member_id)) AS pending_invitation,
|
||||
(SELECT active FROM team_members WHERE id = pm.team_member_id)
|
||||
FROM project_members pm
|
||||
WHERE project_id = $1
|
||||
AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')) pm) AS project_manager
|
||||
FROM projects
|
||||
LEFT JOIN sys_project_statuses sps ON projects.status_id = sps.id
|
||||
WHERE projects.id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, req.user?.id ?? null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data && data.project_manager) {
|
||||
data.project_manager.name = data.project_manager.project_manager_info.name;
|
||||
data.project_manager.email = data.project_manager.project_manager_info.email;
|
||||
data.project_manager.avatar_url = data.project_manager.project_manager_info.avatar_url;
|
||||
data.project_manager.color_code = getColor(data.project_manager.name);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"PROJECT_EXISTS_ERROR": `Project with "{0}" name already exists. Please choose a different project name.`
|
||||
}
|
||||
})
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT update_project($1) AS project;`;
|
||||
|
||||
const key = req.body.key?.toString().trim().toUpperCase();
|
||||
|
||||
if (!key)
|
||||
return res.status(200).send(new ServerResponse(false, null, "The project key cannot be empty."));
|
||||
|
||||
if (key.length > 5)
|
||||
return res.status(200).send(new ServerResponse(false, null, "The project key length cannot exceed 5 characters."));
|
||||
|
||||
req.body.id = req.params.id;
|
||||
req.body.team_id = req.user?.team_id || null;
|
||||
req.body.user_id = req.user?.id || null;
|
||||
req.body.folder_id = req.body.folder_id || null;
|
||||
req.body.category_id = req.body.category_id || null;
|
||||
req.body.client_name = req.body.client_name?.trim() || null;
|
||||
req.body.project_created_log = LOG_DESCRIPTIONS.PROJECT_UPDATED;
|
||||
req.body.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED;
|
||||
req.body.project_member_removed_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_REMOVED;
|
||||
req.body.team_member_id = req.body.project_manager ? req.body.project_manager.id : null;
|
||||
|
||||
const result = await db.query(q, [JSON.stringify(req.body)]);
|
||||
const [data] = result.rows;
|
||||
|
||||
this.notifyProjecManagertUpdates(req.params.id, req.user as IPassportSession, req.body.project_manager ? req.body.project_manager.id : null);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.project));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
AND team_id = $2`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT (SELECT COUNT(id)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = $1
|
||||
AND status_id IN
|
||||
(SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id =
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS done_task_count,
|
||||
|
||||
(SELECT COUNT(id)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = $1
|
||||
AND status_id IN
|
||||
(SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id IN
|
||||
(SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_doing IS TRUE
|
||||
OR is_todo IS TRUE))) AS pending_task_count
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOverviewMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {archived} = req.query;
|
||||
const q = `
|
||||
SELECT team_member_id AS id,
|
||||
FALSE AS active,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = $1
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END) AS project_task_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
INNER JOIN tasks t ON tasks_assignees.task_id = t.id
|
||||
WHERE CASE
|
||||
WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND project_member_id = project_members.id) AS task_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
INNER JOIN tasks t ON tasks_assignees.task_id = t.id
|
||||
INNER JOIN task_statuses ts ON t.status_id = ts.id
|
||||
WHERE CASE
|
||||
WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND project_member_id = project_members.id
|
||||
AND ts.category_id IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE)) AS done_task_count,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
INNER JOIN tasks t ON tasks_assignees.task_id = t.id
|
||||
INNER JOIN task_statuses ts ON t.status_id = ts.id
|
||||
WHERE CASE
|
||||
WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND project_member_id = project_members.id
|
||||
AND end_date::DATE < CURRENT_DATE::DATE
|
||||
AND t.status_id NOT IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id NOT IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS FALSE))) AS overdue_task_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
INNER JOIN tasks t ON tasks_assignees.task_id = t.id
|
||||
INNER JOIN task_statuses ts ON t.status_id = ts.id
|
||||
WHERE CASE
|
||||
WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
AND project_member_id = project_members.id
|
||||
AND ts.category_id IN
|
||||
(SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_doing IS TRUE
|
||||
OR is_todo IS TRUE)) AS pending_task_count,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||
u.avatar_url,
|
||||
(SELECT team_member_info_view.email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, archived === "true"]);
|
||||
|
||||
for (const item of result.rows) {
|
||||
item.progress =
|
||||
item.task_count > 0
|
||||
? ((item.done_task_count / item.task_count) * 100).toFixed(0)
|
||||
: 0;
|
||||
item.contribution =
|
||||
item.project_task_count > 0
|
||||
? ((item.task_count / item.project_task_count) * 100).toFixed(0)
|
||||
: 0;
|
||||
item.tasks = [];
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getAllTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {searchQuery, size, offset} = this.toPaginationOptions(req.query, ["tasks.name"]);
|
||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||
` AND is_member_of_project(p.id, '${req.user?.id}', $1) ` : "";
|
||||
|
||||
const isDueSoon = req.query.filter == "1";
|
||||
|
||||
const dueSoon = isDueSoon ? "AND tasks.end_date IS NOT NULL" : "";
|
||||
const orderBy = isDueSoon ? "tasks.end_date DESC" : "p.name";
|
||||
const assignedToMe = req.query.filter == "2" ? `
|
||||
AND tasks.id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE team_member_id = (SELECT id
|
||||
FROM team_members
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND team_id = $1))
|
||||
` : "";
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS projects
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT tasks.id,
|
||||
tasks.name,
|
||||
p.team_id,
|
||||
p.name AS project_name,
|
||||
tasks.start_date,
|
||||
tasks.end_date,
|
||||
p.id AS project_id,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT get_task_assignees(tasks.id)) AS names
|
||||
FROM tasks
|
||||
INNER JOIN projects p ON tasks.project_id = p.id
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $2 OFFSET $3) t) AS data
|
||||
FROM tasks
|
||||
INNER JOIN projects p ON tasks.project_id = p.id
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe}) rec;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const project of data?.projects.data || []) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
project.names = this.createTagList(project?.names);
|
||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.projects || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getAllProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id AS value, name AS text
|
||||
FROM projects
|
||||
WHERE team_id = $1
|
||||
ORDER BY name;`;
|
||||
const result = await db.query(q, [req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async toggleFavorite(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT toggle_favorite_project($1, $2);`;
|
||||
const result = await db.query(q, [req.user?.id, req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async toggleArchive(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT toggle_archive_project($1, $2);`;
|
||||
const result = await db.query(q, [req.user?.id, req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async toggleArchiveAll(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT toggle_archive_all_projects($1);`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows || []));
|
||||
}
|
||||
|
||||
public static async getProjectManager(projectId: string) {
|
||||
const q = `SELECT team_member_id FROM project_members WHERE project_id = $1 AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')`;
|
||||
const result = await db.query(q, [projectId]);
|
||||
return result.rows || [];
|
||||
}
|
||||
|
||||
}
|
||||
1844
worklenz-backend/src/controllers/reporting-controller.ts
Normal file
1844
worklenz-backend/src/controllers/reporting-controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
60
worklenz-backend/src/controllers/reporting/interfaces.ts
Normal file
60
worklenz-backend/src/controllers/reporting/interfaces.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { IChartObject } from "./overview/reporting-overview-base";
|
||||
|
||||
export interface IDuration {
|
||||
label: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface IReportingInfo {
|
||||
organization_name: string;
|
||||
}
|
||||
|
||||
export interface ITeamStatistics {
|
||||
count: number;
|
||||
projects: number;
|
||||
members: number;
|
||||
}
|
||||
|
||||
export interface IProjectStatistics {
|
||||
count: number;
|
||||
active: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export interface IMemberStatistics {
|
||||
count: number;
|
||||
unassigned: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export interface IOverviewStatistics {
|
||||
teams: ITeamStatistics;
|
||||
projects: IProjectStatistics;
|
||||
members: IMemberStatistics;
|
||||
}
|
||||
|
||||
export interface IChartData {
|
||||
chart: IChartObject[];
|
||||
}
|
||||
|
||||
export interface ITasksByStatus extends IChartData {
|
||||
all: number;
|
||||
todo: number;
|
||||
doing: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface ITasksByPriority extends IChartData {
|
||||
all: number;
|
||||
low: number;
|
||||
medium: number;
|
||||
high: number;
|
||||
}
|
||||
|
||||
export interface ITasksByDue extends IChartData {
|
||||
all: number;
|
||||
completed: number;
|
||||
upcoming: number;
|
||||
overdue: number;
|
||||
no_due: number;
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
import db from "../../../config/db";
|
||||
import { ITasksByDue, ITasksByPriority, ITasksByStatus } from "../interfaces";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
import {
|
||||
DATE_RANGES,
|
||||
TASK_DUE_COMPLETED_COLOR,
|
||||
TASK_DUE_NO_DUE_COLOR,
|
||||
TASK_DUE_OVERDUE_COLOR,
|
||||
TASK_DUE_UPCOMING_COLOR,
|
||||
TASK_PRIORITY_HIGH_COLOR,
|
||||
TASK_PRIORITY_LOW_COLOR,
|
||||
TASK_PRIORITY_MEDIUM_COLOR,
|
||||
TASK_STATUS_DOING_COLOR,
|
||||
TASK_STATUS_DONE_COLOR,
|
||||
TASK_STATUS_TODO_COLOR
|
||||
} from "../../../shared/constants";
|
||||
import { formatDuration, int } from "../../../shared/utils";
|
||||
import moment from "moment";
|
||||
|
||||
export interface IChartObject {
|
||||
name: string,
|
||||
color: string,
|
||||
y: number
|
||||
}
|
||||
|
||||
export default class ReportingOverviewBase extends ReportingControllerBase {
|
||||
|
||||
private static createChartObject(name: string, color: string, y: number) {
|
||||
return {
|
||||
name,
|
||||
color,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTeamsCounts(teamId: string | null, archivedQuery = "") {
|
||||
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'teams', (SELECT COUNT(*) FROM teams WHERE in_organization(id, $1)),
|
||||
'projects',
|
||||
(SELECT COUNT(*) FROM projects WHERE in_organization(team_id, $1) ${archivedQuery}),
|
||||
'team_members', (SELECT COUNT(DISTINCT email)
|
||||
FROM team_member_info_view
|
||||
WHERE in_organization(team_id, $1))
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
count: int(data?.counts.teams),
|
||||
projects: int(data?.counts.projects),
|
||||
members: int(data?.counts.team_members),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getProjectsCounts(teamId: string | null, archivedQuery = "") {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'active_projects', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE in_organization(team_id, $1) AND (end_date > CURRENT_TIMESTAMP
|
||||
OR end_date IS NULL) ${archivedQuery}),
|
||||
'overdue_projects', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE in_organization(team_id, $1)
|
||||
AND end_date < CURRENT_TIMESTAMP
|
||||
AND status_id NOT IN
|
||||
(SELECT id FROM sys_project_statuses WHERE name = 'Completed') ${archivedQuery})
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
count: 0,
|
||||
active: int(data?.counts.active_projects),
|
||||
overdue: int(data?.counts.overdue_projects),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getMemberCounts(teamId: string | null) {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'unassigned', (SELECT COUNT(*)
|
||||
FROM team_members
|
||||
WHERE in_organization(team_id, $1)
|
||||
AND id NOT IN (SELECT team_member_id FROM tasks_assignees)),
|
||||
'with_overdue', (SELECT COUNT(*)
|
||||
FROM team_members
|
||||
WHERE in_organization(team_id, $1)
|
||||
AND id IN (SELECT team_member_id
|
||||
FROM tasks_assignees
|
||||
WHERE is_overdue(task_id) IS TRUE))
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
count: 0,
|
||||
unassigned: int(data?.counts.unassigned),
|
||||
overdue: int(data?.counts.with_overdue),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getProjectStats(projectId: string | null) {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'completed', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_completed(tasks.status_id, tasks.project_id) IS TRUE),
|
||||
'incompleted', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_completed(tasks.status_id, tasks.project_id) IS FALSE),
|
||||
'overdue', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_overdue(tasks.id)),
|
||||
'total_allocated', (SELECT SUM(total_minutes)
|
||||
FROM tasks
|
||||
WHERE project_id = $1),
|
||||
'total_logged', (SELECT SUM((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = tasks.id))
|
||||
FROM tasks
|
||||
WHERE project_id = $1)
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
completed: int(data?.counts.completed),
|
||||
incompleted: int(data?.counts.incompleted),
|
||||
overdue: int(data?.counts.overdue),
|
||||
total_allocated: moment.duration(int(data?.counts.total_allocated), "minutes").asHours().toFixed(0),
|
||||
total_logged: moment.duration(int(data?.counts.total_logged), "seconds").asHours().toFixed(0),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTasksByStatus(projectId: string | null): Promise<ITasksByStatus> {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'all', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1),
|
||||
'todo', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_todo(tasks.status_id, tasks.project_id) IS TRUE),
|
||||
'doing', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_doing(tasks.status_id, tasks.project_id) IS TRUE),
|
||||
'done', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND is_completed(tasks.status_id, tasks.project_id) IS TRUE)
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const all = int(data?.counts.all);
|
||||
const todo = int(data?.counts.todo);
|
||||
const doing = int(data?.counts.doing);
|
||||
const done = int(data?.counts.done);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
return {
|
||||
all,
|
||||
todo,
|
||||
doing,
|
||||
done,
|
||||
chart
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTasksByPriority(projectId: string | null): Promise<ITasksByPriority> {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'low', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND priority_id = (SELECT id FROM task_priorities WHERE value = 0)),
|
||||
'medium', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND priority_id = (SELECT id FROM task_priorities WHERE value = 1)),
|
||||
'high', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND priority_id = (SELECT id FROM task_priorities WHERE value = 2))
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const low = int(data?.counts.low);
|
||||
const medium = int(data?.counts.medium);
|
||||
const high = int(data?.counts.high);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
return {
|
||||
all: 0,
|
||||
low,
|
||||
medium,
|
||||
high,
|
||||
chart
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTaskCountsByDue(projectId: string | null): Promise<ITasksByDue> {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'no_due', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND end_date IS NULL),
|
||||
'upcoming', (SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND end_date > CURRENT_TIMESTAMP)
|
||||
) AS counts;
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [projectId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
return {
|
||||
all: 0,
|
||||
completed: 0,
|
||||
upcoming: int(data?.counts.upcoming),
|
||||
overdue: 0,
|
||||
no_due: int(data?.counts.no_due),
|
||||
chart
|
||||
};
|
||||
}
|
||||
|
||||
protected static createByStatusChartData(body: ITasksByStatus) {
|
||||
body.chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, body.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, body.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, body.done),
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByPriorityChartData(body: ITasksByPriority) {
|
||||
body.chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, body.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, body.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, body.high),
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByDueDateChartData(body: ITasksByDue) {
|
||||
body.chart = [
|
||||
this.createChartObject("Completed", TASK_DUE_COMPLETED_COLOR, body.completed),
|
||||
this.createChartObject("Upcoming", TASK_DUE_UPCOMING_COLOR, body.upcoming),
|
||||
this.createChartObject("Overdue", TASK_DUE_OVERDUE_COLOR, body.overdue),
|
||||
this.createChartObject("No due date", TASK_DUE_NO_DUE_COLOR, body.no_due),
|
||||
];
|
||||
}
|
||||
|
||||
// Team Member Overview
|
||||
|
||||
protected static async getProjectCountOfTeamMember(teamMemberId: string | null, includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND pm.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = pm.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(*)
|
||||
FROM project_members pm
|
||||
WHERE team_member_id = $1 ${archivedClause};
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
const [data] = result.rows;
|
||||
return int(data.count);
|
||||
}
|
||||
|
||||
protected static async getTeamCountOfTeamMember(teamMemberId: string | null) {
|
||||
const q = `
|
||||
SELECT COUNT(*)
|
||||
FROM team_members
|
||||
WHERE id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
const [data] = result.rows;
|
||||
return int(data.count);
|
||||
}
|
||||
|
||||
protected static memberTasksDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `AND t.end_date::DATE >= '${start}'::DATE AND t.end_date::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
|
||||
protected static activityLogDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
|
||||
return `
|
||||
AND (is_doing(
|
||||
(SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' AND tl.created_at::DATE <= '${end}'::DATE ORDER BY tl.created_at DESC LIMIT 1
|
||||
)::UUID, t.project_id)
|
||||
OR is_todo(
|
||||
(SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' AND tl.created_at::DATE <= '${end}'::DATE ORDER BY tl.created_at DESC LIMIT 1
|
||||
)::UUID, t.project_id)
|
||||
OR is_completed_between(t.id::UUID, '${start}'::DATE, '${end}'::DATE))`;
|
||||
}
|
||||
return `AND (is_doing(
|
||||
(SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' AND tl.created_at::DATE <= NOW()::DATE ORDER BY tl.created_at DESC LIMIT 1
|
||||
)::UUID, t.project_id)
|
||||
OR is_todo(
|
||||
(SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' AND tl.created_at::DATE <= NOW()::DATE ORDER BY tl.created_at DESC LIMIT 1
|
||||
)::UUID, t.project_id)
|
||||
OR is_completed(t.status_id::UUID, t.project_id::UUID))`;
|
||||
}
|
||||
|
||||
protected static memberAssignDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
|
||||
if (start === end) {
|
||||
return `AND ta.updated_at::DATE = '${start}'::DATE`;
|
||||
}
|
||||
|
||||
return `AND ta.updated_at::DATE >= '${start}'::DATE AND ta.updated_at::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static completedDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
|
||||
if (start === end) {
|
||||
return `AND t.completed_at::DATE = '${start}'::DATE`;
|
||||
}
|
||||
|
||||
return `AND t.completed_at::DATE >= '${start}'::DATE AND t.completed_at::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static overdueTasksByDate(key: string, dateRange: string[], archivedClause: string) {
|
||||
if (dateRange.length === 2) {
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `(SELECT COUNT(CASE WHEN is_overdue_for_date(t.id, '${end}'::DATE) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause})`;
|
||||
}
|
||||
|
||||
return `(SELECT COUNT(CASE WHEN is_overdue_for_date(t.id, NOW()::DATE) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause})`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
protected static overdueTasksDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `AND t.end_date::DATE >= '${start}'::DATE AND t.end_date::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < NOW()::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < NOW()::DATE`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < NOW()::DATE`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < NOW()::DATE`;
|
||||
if (key === DATE_RANGES.ALL_TIME)
|
||||
return `AND t.end_date::DATE < NOW()::DATE`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static taskWorklogDurationFilter(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `AND created_at::DATE >= '${start}'::DATE AND created_at::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return `AND created_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND created_at::DATE < CURRENT_DATE::DATE`;
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return `AND created_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND created_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return `AND created_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND created_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return `AND created_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND created_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static async getTeamMemberStats(teamMemberId: string | null, includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
const q = `SELECT JSON_BUILD_OBJECT(
|
||||
|
||||
'total_tasks', (SELECT COUNT(ta.task_id)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause}),
|
||||
|
||||
'completed', (SELECT COUNT(CASE WHEN is_completed(t.status_id, t.project_id) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause}),
|
||||
|
||||
'ongoing', (SELECT COUNT(CASE WHEN is_doing(t.status_id, t.project_id) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause}),
|
||||
|
||||
'overdue', (SELECT COUNT(CASE WHEN is_overdue(t.id) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause}),
|
||||
|
||||
'total_logged', (SELECT SUM((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND user_id = (SELECT user_id FROM team_member_info_view WHERE team_member_info_view.team_member_id = $1) ${archivedClause})) AS total_logged
|
||||
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1)
|
||||
) AS counts;`;
|
||||
|
||||
const res = await db.query(q, [teamMemberId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
teams: 0,
|
||||
projects: 0,
|
||||
completed: int(data?.counts.completed),
|
||||
ongoing: int(data?.counts.ongoing),
|
||||
overdue: int(data?.counts.overdue),
|
||||
total_tasks: int(data?.counts.total_tasks),
|
||||
total_logged: formatDuration(moment.duration(data?.counts.total_logged, "seconds")),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getMemberStats(teamMemberId: string | null, key: string, dateRange: string[] | [], includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
const durationFilter = this.memberTasksDurationFilter(key, dateRange);
|
||||
const workLogDurationFilter = this.taskWorklogDurationFilter(key, dateRange);
|
||||
const assignClause = this.memberAssignDurationFilter(key, dateRange);
|
||||
const completedDurationClasue = this.completedDurationFilter(key, dateRange);
|
||||
const overdueClauseByDate = this.overdueTasksByDate(key, dateRange, archivedClause);
|
||||
|
||||
const q = `SELECT JSON_BUILD_OBJECT(
|
||||
|
||||
'total_tasks', (SELECT COUNT(ta.task_id)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${durationFilter} ${archivedClause}),
|
||||
|
||||
'assigned', (SELECT COUNT(ta.task_id)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${assignClause} ${archivedClause}),
|
||||
|
||||
'completed', (SELECT COUNT(CASE WHEN is_completed(t.status_id, t.project_id) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${completedDurationClasue} ${archivedClause}),
|
||||
|
||||
'ongoing', (SELECT COUNT(CASE WHEN is_doing(t.status_id, t.project_id) IS TRUE THEN 1 END)
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause}),
|
||||
|
||||
'overdue', ${overdueClauseByDate},
|
||||
|
||||
'total_logged', (SELECT SUM((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND user_id = (SELECT user_id FROM team_member_info_view WHERE team_member_info_view.team_member_id = $1) ${workLogDurationFilter} ${archivedClause})) AS total_logged
|
||||
|
||||
FROM tasks t
|
||||
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1)
|
||||
) AS counts;`;
|
||||
|
||||
const res = await db.query(q, [teamMemberId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
return {
|
||||
teams: 0,
|
||||
projects: 0,
|
||||
assigned: int(data?.counts.assigned),
|
||||
completed: int(data?.counts.completed),
|
||||
ongoing: int(data?.counts.ongoing),
|
||||
overdue: int(data?.counts.overdue),
|
||||
total_tasks: int(data?.counts.total_tasks),
|
||||
total_logged: formatDuration(moment.duration(data?.counts.total_logged, "seconds")),
|
||||
};
|
||||
}
|
||||
|
||||
protected static async getTasksByProjectOfTeamMemberOverview(teamMemberId: string | null, includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.color_code AS color,
|
||||
p.name AS label,
|
||||
COUNT(t.id) AS count
|
||||
FROM projects p
|
||||
JOIN tasks t ON p.id = t.project_id
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id AND ta.team_member_id = $1
|
||||
JOIN project_members pm ON p.id = pm.project_id AND pm.team_member_id = $1
|
||||
WHERE (is_doing(t.status_id, t.project_id)
|
||||
OR is_todo(t.status_id, t.project_id)
|
||||
OR is_completed(t.status_id, t.project_id)) ${archivedClause}
|
||||
GROUP BY p.id, p.name;
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
}) => accumulator + int(current.count), 0);
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.count = int(project.count);
|
||||
chart.push(this.createChartObject(project.label, project.color, project.count));
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
}
|
||||
|
||||
protected static async getTasksByProjectOfTeamMember(teamMemberId: string | null, key: string, dateRange: string[] | [], includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
const durationFilter = this.memberTasksDurationFilter(key, dateRange);
|
||||
const activityLogDateFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||
const completedDatebetweenClause = this.getCompletedBetweenClause(key, dateRange);
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.color_code AS color,
|
||||
p.name AS label,
|
||||
COUNT(t.id) AS count
|
||||
FROM projects p
|
||||
JOIN tasks t ON p.id = t.project_id
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id AND ta.team_member_id = $1
|
||||
JOIN project_members pm ON p.id = pm.project_id AND pm.team_member_id = $1
|
||||
WHERE (is_doing(
|
||||
(SELECT new_value
|
||||
FROM task_activity_logs tl
|
||||
WHERE tl.task_id = t.id
|
||||
AND tl.attribute_type = 'status'
|
||||
${activityLogDateFilter}
|
||||
ORDER BY tl.created_at DESC
|
||||
LIMIT 1)::UUID, t.project_id)
|
||||
OR is_todo(
|
||||
(SELECT new_value
|
||||
FROM task_activity_logs tl
|
||||
WHERE tl.task_id = t.id
|
||||
AND tl.attribute_type = 'status'
|
||||
${activityLogDateFilter}
|
||||
ORDER BY tl.created_at DESC
|
||||
LIMIT 1)::UUID, t.project_id)
|
||||
OR ${completedDatebetweenClause}) ${archivedClause}
|
||||
GROUP BY p.id, p.name;
|
||||
`;
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
}) => accumulator + int(current.count), 0);
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.count = int(project.count);
|
||||
chart.push(this.createChartObject(project.label, project.color, project.count));
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
}
|
||||
|
||||
protected static async getTasksByPriorityOfTeamMemberOverview(teamMemberId: string | null, includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(CASE WHEN tp.value = 0 THEN 1 END) AS low,
|
||||
COUNT(CASE WHEN tp.value = 1 THEN 1 END) AS medium,
|
||||
COUNT(CASE WHEN tp.value = 2 THEN 1 END) AS high
|
||||
FROM tasks t
|
||||
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 AND (is_doing(t.status_id, t.project_id)
|
||||
OR is_todo(t.status_id, t.project_id)
|
||||
OR is_completed(t.status_id, t.project_id)) ${archivedClause};
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
const [d] = result.rows;
|
||||
|
||||
const total = int(d.low) + int(d.medium) + int(d.high);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ label: "Low", color: TASK_PRIORITY_LOW_COLOR, count: d.low },
|
||||
{ label: "Medium", color: TASK_PRIORITY_MEDIUM_COLOR, count: d.medium },
|
||||
{ label: "High", color: TASK_PRIORITY_HIGH_COLOR, count: d.high },
|
||||
];
|
||||
|
||||
return { chart, total, data };
|
||||
}
|
||||
|
||||
protected static async getTasksByPriorityOfTeamMember(teamMemberId: string | null, key: string, dateRange: string[] | [], includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
|
||||
const durationFilter = this.memberTasksDurationFilter(key, dateRange);
|
||||
const activityLogDateFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||
const completedDatebetweenClause = this.getCompletedBetweenClause(key, dateRange);
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(CASE WHEN tp.value = 0 THEN 1 END) AS low,
|
||||
COUNT(CASE WHEN tp.value = 1 THEN 1 END) AS medium,
|
||||
COUNT(CASE WHEN tp.value = 2 THEN 1 END) AS high
|
||||
FROM tasks t
|
||||
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 AND (is_doing(
|
||||
(SELECT new_value
|
||||
FROM task_activity_logs tl
|
||||
WHERE tl.task_id = t.id
|
||||
AND tl.attribute_type = 'status'
|
||||
${activityLogDateFilter}
|
||||
ORDER BY tl.created_at DESC
|
||||
LIMIT 1)::UUID, t.project_id)
|
||||
OR is_todo(
|
||||
(SELECT new_value
|
||||
FROM task_activity_logs tl
|
||||
WHERE tl.task_id = t.id
|
||||
AND tl.attribute_type = 'status'
|
||||
${activityLogDateFilter}
|
||||
ORDER BY tl.created_at DESC
|
||||
LIMIT 1)::UUID, t.project_id)
|
||||
OR ${completedDatebetweenClause}) ${archivedClause};
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamMemberId]);
|
||||
const [d] = result.rows;
|
||||
|
||||
const total = int(d.low) + int(d.medium) + int(d.high);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
|
||||
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
|
||||
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ label: "Low", color: TASK_PRIORITY_LOW_COLOR, count: d.low },
|
||||
{ label: "Medium", color: TASK_PRIORITY_MEDIUM_COLOR, count: d.medium },
|
||||
{ label: "High", color: TASK_PRIORITY_HIGH_COLOR, count: d.high },
|
||||
];
|
||||
|
||||
return { chart, total, data };
|
||||
}
|
||||
|
||||
protected static getActivityLogsCreationClause(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `AND tl.created_at::DATE <= '${end}'::DATE`;
|
||||
}
|
||||
return `AND tl.created_at::DATE <= NOW()::DATE`;
|
||||
}
|
||||
|
||||
protected static getCompletedBetweenClause(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `is_completed_between(t.id::UUID, '${start}'::DATE, '${end}'::DATE)`;
|
||||
}
|
||||
return `is_completed(t.status_id::UUID, t.project_id::UUID)`;
|
||||
}
|
||||
|
||||
protected static async getTasksByStatusOfTeamMemberOverview(teamMemberId: string | null, includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(ta.task_id) AS total,
|
||||
COUNT(CASE WHEN is_todo(t.status_id, t.project_id) IS TRUE THEN 1 END) AS todo,
|
||||
COUNT(CASE WHEN is_doing(t.status_id, t.project_id) IS TRUE THEN 1 END) AS doing,
|
||||
COUNT(CASE WHEN is_completed(t.status_id, t.project_id) IS TRUE THEN 1 END) AS done
|
||||
FROM tasks t
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause};
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [teamMemberId]);
|
||||
const [d] = res.rows;
|
||||
|
||||
const total = int(d.total);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ label: "Todo", color: TASK_STATUS_TODO_COLOR, count: d.todo },
|
||||
{ label: "Doing", color: TASK_STATUS_DOING_COLOR, count: d.doing },
|
||||
{ label: "Done", color: TASK_STATUS_DONE_COLOR, count: d.done },
|
||||
];
|
||||
|
||||
|
||||
return { chart, total, data };
|
||||
}
|
||||
|
||||
protected static async getTasksByStatusOfTeamMember(teamMemberId: string | null, key: string, dateRange: string[] | [], includeArchived: boolean, userId: string) {
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`;
|
||||
|
||||
|
||||
const durationFilter = this.memberTasksDurationFilter(key, dateRange);
|
||||
const completedBetweenFilter = this.getCompletedBetweenClause(key, dateRange);
|
||||
const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange);
|
||||
|
||||
const q = `
|
||||
SELECT COUNT(ta.task_id) AS total,
|
||||
COUNT(CASE WHEN is_todo((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) IS TRUE THEN 1 END) AS todo,
|
||||
COUNT(CASE WHEN is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) IS TRUE THEN 1 END) AS doing,
|
||||
COUNT(CASE WHEN ${completedBetweenFilter} IS TRUE THEN 1 END) AS done
|
||||
FROM tasks t
|
||||
JOIN tasks_assignees ta ON t.id = ta.task_id
|
||||
WHERE ta.team_member_id = $1 ${archivedClause};
|
||||
`;
|
||||
|
||||
const res = await db.query(q, [teamMemberId]);
|
||||
const [d] = res.rows;
|
||||
|
||||
const total = int(d.todo) + int(d.doing) + int(d.done);
|
||||
|
||||
const chart = [
|
||||
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
|
||||
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
|
||||
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ label: "Todo", color: TASK_STATUS_TODO_COLOR, count: d.todo },
|
||||
{ label: "Doing", color: TASK_STATUS_DOING_COLOR, count: d.doing },
|
||||
{ label: "Done", color: TASK_STATUS_DONE_COLOR, count: d.done },
|
||||
];
|
||||
|
||||
return { chart, total, data };
|
||||
}
|
||||
|
||||
protected static async getProjectsByStatus(teamId: string | null, archivedClause = ""): Promise<any> {
|
||||
const q = `WITH ProjectCounts AS (
|
||||
SELECT
|
||||
COUNT(*) AS all_projects,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'Cancelled') THEN 1 ELSE 0 END) AS cancelled,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'Blocked') THEN 1 ELSE 0 END) AS blocked,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'On Hold') THEN 1 ELSE 0 END) AS on_hold,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'Proposed') THEN 1 ELSE 0 END) AS proposed,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'In Planning') THEN 1 ELSE 0 END) AS in_planning,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'In Progress') THEN 1 ELSE 0 END) AS in_progress,
|
||||
SUM(CASE WHEN status_id = (SELECT id FROM sys_project_statuses WHERE name = 'Completed') THEN 1 ELSE 0 END) AS completed
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${archivedClause})
|
||||
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'all_projects', all_projects,
|
||||
'cancelled', cancelled,
|
||||
'blocked', blocked,
|
||||
'on_hold', on_hold,
|
||||
'proposed', proposed,
|
||||
'in_planning', in_planning,
|
||||
'in_progress', in_progress,
|
||||
'completed', completed
|
||||
) AS counts
|
||||
FROM ProjectCounts;`;
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const all = int(data?.counts.all_projects);
|
||||
const cancelled = int(data?.counts.cancelled);
|
||||
const blocked = int(data?.counts.blocked);
|
||||
const on_hold = int(data?.counts.on_hold);
|
||||
const proposed = int(data?.counts.proposed);
|
||||
const in_planning = int(data?.counts.in_planning);
|
||||
const in_progress = int(data?.counts.in_progress);
|
||||
const completed = int(data?.counts.completed);
|
||||
|
||||
const chart : IChartObject[] = [];
|
||||
|
||||
return {
|
||||
all,
|
||||
cancelled,
|
||||
blocked,
|
||||
on_hold,
|
||||
proposed,
|
||||
in_planning,
|
||||
in_progress,
|
||||
completed,
|
||||
chart
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
protected static async getProjectsByCategory(teamId: string | null, archivedClause = ""): Promise<any> {
|
||||
const q = `
|
||||
SELECT
|
||||
pc.id,
|
||||
pc.color_code AS color,
|
||||
pc.name AS label,
|
||||
COUNT(pc.id) AS count
|
||||
FROM project_categories pc
|
||||
JOIN projects ON pc.id = projects.category_id
|
||||
WHERE projects.team_id = $1 ${archivedClause}
|
||||
GROUP BY pc.id, pc.name;
|
||||
`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
const total = result.rows.reduce((accumulator: number, current: {
|
||||
count: number
|
||||
}) => accumulator + int(current.count), 0);
|
||||
|
||||
for (const category of result.rows) {
|
||||
category.count = int(category.count);
|
||||
chart.push({
|
||||
name: category.label,
|
||||
color: category.color,
|
||||
y: category.count
|
||||
});
|
||||
}
|
||||
|
||||
return { chart, total, data: result.rows };
|
||||
|
||||
}
|
||||
|
||||
protected static async getProjectsByHealth(teamId: string | null, archivedClause = ""): Promise<any> {
|
||||
const q = `
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'needs_attention', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${archivedClause}
|
||||
AND health_id = (SELECT id FROM sys_project_healths WHERE name = 'Needs Attention')),
|
||||
'at_risk', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${archivedClause}
|
||||
AND health_id = (SELECT id FROM sys_project_healths WHERE name = 'At Risk')),
|
||||
'good', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${archivedClause}
|
||||
AND health_id = (SELECT id FROM sys_project_healths WHERE name = 'Good')),
|
||||
'not_set', (SELECT COUNT(*)
|
||||
FROM projects
|
||||
WHERE team_id = $1 ${archivedClause}
|
||||
AND health_id = (SELECT id FROM sys_project_healths WHERE name = 'Not Set'))
|
||||
) AS counts;
|
||||
`;
|
||||
const res = await db.query(q, [teamId]);
|
||||
const [data] = res.rows;
|
||||
|
||||
const not_set = int(data?.counts.not_set);
|
||||
const needs_attention = int(data?.counts.needs_attention);
|
||||
const at_risk = int(data?.counts.at_risk);
|
||||
const good = int(data?.counts.good);
|
||||
|
||||
const chart: IChartObject[] = [];
|
||||
|
||||
return {
|
||||
not_set,
|
||||
needs_attention,
|
||||
at_risk,
|
||||
good,
|
||||
chart
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// Team Overview
|
||||
protected static createByProjectStatusChartData(body: any) {
|
||||
body.chart = [
|
||||
this.createChartObject("Cancelled", "#f37070", body.cancelled),
|
||||
this.createChartObject("Blocked", "#cbc8a1", body.blocked),
|
||||
this.createChartObject("On Hold", "#cbc8a1", body.on_hold),
|
||||
this.createChartObject("Proposed", "#cbc8a1", body.proposed),
|
||||
this.createChartObject("In Planning", "#cbc8a1", body.in_planning),
|
||||
this.createChartObject("In Progress", "#80ca79", body.in_progress),
|
||||
this.createChartObject("Completed", "#80ca79", body.completed)
|
||||
];
|
||||
}
|
||||
|
||||
protected static createByProjectHealthChartData(body: any) {
|
||||
body.chart = [
|
||||
this.createChartObject("Not Set", "#a9a9a9", body.not_set),
|
||||
this.createChartObject("Needs Attention", "#f37070", body.needs_attention),
|
||||
this.createChartObject("At Risk", "#fbc84c", body.at_risk),
|
||||
this.createChartObject("Good", "#75c997", body.good)
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import HandleExceptions from "../../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../../models/server-response";
|
||||
import db from "../../../config/db";
|
||||
import { formatDuration, formatLogText, getColor, int } from "../../../shared/utils";
|
||||
import ReportingOverviewBase from "./reporting-overview-base";
|
||||
import { GroupBy, ITaskGroup } from "../../tasks-controller-base";
|
||||
import TasksControllerV2, { TaskListGroup } from "../../tasks-controller-v2";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA } from "../../../shared/constants";
|
||||
import { ReportingExportModel } from "../../../models/reporting-export";
|
||||
import moment from "moment";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
|
||||
export default class ReportingOverviewController extends ReportingOverviewBase {
|
||||
@HandleExceptions()
|
||||
public static async getStatistics(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = this.getCurrentTeamId(req);
|
||||
const includeArchived = req.query.archived === "true";
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const teams = await this.getTeamsCounts(teamId, archivedClause);
|
||||
const projects = await this.getProjectsCounts(teamId, archivedClause);
|
||||
const members = await this.getMemberCounts(teamId);
|
||||
|
||||
projects.count = teams.projects;
|
||||
members.count = teams.members;
|
||||
|
||||
const body = {
|
||||
teams,
|
||||
projects,
|
||||
members
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = this.getCurrentTeamId(req);
|
||||
const includeArchived = req.query.archived === "true";
|
||||
|
||||
const archivedClause = includeArchived
|
||||
? ""
|
||||
: `AND id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND archived_projects.user_id = '${req.user?.id}')`;
|
||||
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
COALESCE((SELECT COUNT(*) FROM projects WHERE team_id = teams.id ${archivedClause}), 0) AS projects_count,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (
|
||||
--
|
||||
SELECT (SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
u.avatar_url
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE team_id = teams.id
|
||||
--
|
||||
) rec) AS members
|
||||
FROM teams
|
||||
WHERE in_organization(id, $1)
|
||||
ORDER BY name;
|
||||
`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
for (const team of result.rows) {
|
||||
team.members = this.createTagList(team?.members);
|
||||
team.members.map((a: any) => a.color_code = getColor(a.name));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"]);
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teamId = req.query.team as string;
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const teamFilterClause = `p.team_id = $1`;
|
||||
|
||||
const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField, sortOrder, "", "", "", archivedClause, teamFilterClause, "");
|
||||
|
||||
|
||||
for (const project of result.projects) {
|
||||
project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
project.days_left = ReportingControllerBase.getDaysLeft(project.end_date);
|
||||
project.is_overdue = ReportingControllerBase.isOverdue(project.end_date);
|
||||
if (project.days_left && project.is_overdue) {
|
||||
project.days_left = project.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
project.is_today = this.isToday(project.end_date);
|
||||
project.estimated_time = int(project.estimated_time);
|
||||
project.actual_time = int(project.actual_time);
|
||||
project.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(project.estimated_time));
|
||||
project.actual_time_string = this.convertSecondsToHoursAndMinutes(int(project.actual_time));
|
||||
project.tasks_stat = {
|
||||
todo: this.getPercentage(int(project.tasks_stat.todo), +project.tasks_stat.total),
|
||||
doing: this.getPercentage(int(project.tasks_stat.doing), +project.tasks_stat.total),
|
||||
done: this.getPercentage(int(project.tasks_stat.done), +project.tasks_stat.total)
|
||||
};
|
||||
if (project.update.length > 0) {
|
||||
const update = project.update[0];
|
||||
const placeHolders = update.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < update.mentions.length) {
|
||||
update.content = update.content.replace(placeHolder, `
|
||||
<span class='mentions'> @${update.mentions[index].user_name} </span>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
project.comment = update.content;
|
||||
}
|
||||
if (project.last_activity) {
|
||||
if (project.last_activity.attribute_type === "estimation") {
|
||||
project.last_activity.previous = formatDuration(moment.duration(project.last_activity.previous, "minutes"));
|
||||
project.last_activity.current = formatDuration(moment.duration(project.last_activity.current, "minutes"));
|
||||
}
|
||||
if (project.last_activity.assigned_user) project.last_activity.assigned_user.color_code = getColor(project.last_activity.assigned_user.name);
|
||||
project.last_activity.done_by.color_code = getColor(project.last_activity.done_by.name);
|
||||
project.last_activity.log_text = await formatLogText(project.last_activity);
|
||||
project.last_activity.attribute_type = project.last_activity.attribute_type?.replace(/_/g, " ");
|
||||
project.last_activity.last_activity_string = `${project.last_activity.done_by.name} ${project.last_activity.log_text} ${project.last_activity.attribute_type}`;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectsByTeamOrMember(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = req.params.team_id?.trim() || null;
|
||||
const teamMemberId = (req.query.member as string)?.trim() || null;
|
||||
const teamMemberFilter = teamId === "undefined" ? `AND pm.team_member_id = $1` : teamMemberId ? `AND pm.team_member_id = $2` : "";
|
||||
const teamIdFilter = teamId === "undefined" ? "p.team_id IS NOT NULL" : `p.team_id = $1`;
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
p.color_code,
|
||||
p.team_id,
|
||||
p.status_id
|
||||
FROM projects p
|
||||
LEFT JOIN project_members pm ON pm.project_id = p.id
|
||||
WHERE ${teamIdFilter} ${teamMemberFilter}
|
||||
GROUP BY p.id, p.name;`;
|
||||
|
||||
const params = teamId === "undefined" ? [teamMemberId] : teamMemberId ? [teamId, teamMemberId] : [teamId];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
|
||||
const data = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMembersByTeam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = req.params.team_id?.trim() || null;
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const pmArchivedClause = archived ? `` : `AND project_members.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = project_members.project_id AND user_id = '${req.user?.id}')`;
|
||||
const taArchivedClause = archived ? `` : `AND (SELECT tasks.project_id FROM tasks WHERE tasks.id = tasks_assignees.task_id) NOT IN (SELECT project_id FROM archived_projects WHERE project_id = (SELECT tasks.project_id FROM tasks WHERE tasks.id = tasks_assignees.task_id) AND user_id = '${req.user?.id}')`;
|
||||
|
||||
const q = `
|
||||
SELECT team_member_id AS id,
|
||||
name,
|
||||
email,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_members.team_member_id = team_member_info_view.team_member_id ${pmArchivedClause}) AS projects,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.team_member_id = team_member_info_view.team_member_id ${taArchivedClause}) AS tasks,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.team_member_id = team_member_info_view.team_member_id
|
||||
AND is_overdue(task_id) IS TRUE ${taArchivedClause}) AS overdue,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.team_member_id = team_member_info_view.team_member_id
|
||||
AND task_id IN (SELECT id
|
||||
FROM tasks
|
||||
WHERE is_completed(tasks.status_id, tasks.project_id)) ${taArchivedClause}) AS completed,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.team_member_id = team_member_info_view.team_member_id
|
||||
AND task_id IN (SELECT id
|
||||
FROM tasks
|
||||
WHERE is_doing(tasks.status_id, tasks.project_id)) ${taArchivedClause}) AS ongoing
|
||||
|
||||
FROM team_member_info_view
|
||||
WHERE team_id = $1
|
||||
ORDER BY name;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.projects = int(member.projects);
|
||||
member.tasks = int(member.tasks);
|
||||
member.overdue = int(member.overdue);
|
||||
member.completed = int(member.completed);
|
||||
member.ongoing = int(member.ongoing);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.params.project_id || null;
|
||||
|
||||
const stats = await this.getProjectStats(projectId);
|
||||
const byStatus = await this.getTasksByStatus(projectId);
|
||||
const byPriority = await this.getTasksByPriority(projectId);
|
||||
const byDue = await this.getTaskCountsByDue(projectId);
|
||||
|
||||
byPriority.all = byStatus.all;
|
||||
|
||||
byDue.all = byStatus.all;
|
||||
byDue.completed = stats.completed;
|
||||
byDue.overdue = stats.overdue;
|
||||
|
||||
const body = {
|
||||
stats,
|
||||
by_status: byStatus,
|
||||
by_priority: byPriority,
|
||||
by_due: byDue
|
||||
};
|
||||
|
||||
this.createByStatusChartData(body.by_status);
|
||||
this.createByPriorityChartData(body.by_priority);
|
||||
this.createByDueDateChartData(body.by_due);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.params.project_id?.trim() || null;
|
||||
|
||||
const members = await ReportingExportModel.getProjectMembers(projectId as string);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, members));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
const projectId = req.params.project_id?.trim() || null;
|
||||
|
||||
const groups = await TasksControllerV2.getGroups(groupBy, projectId as string);
|
||||
const tasks = await this.getAllTasks(projectId);
|
||||
|
||||
const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => {
|
||||
if (group.id)
|
||||
g[group.id] = new TaskListGroup(group);
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
TasksControllerV2.updateMapByGroup(tasks, groupBy, map);
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
if (groupBy === GroupBy.PHASE)
|
||||
group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
return {
|
||||
id: key,
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTeamMemberOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamMemberId = req.query.teamMemberId as string;
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const stats = await this.getTeamMemberStats(teamMemberId, archived, req.user?.id as string);
|
||||
const byStatus = await this.getTasksByStatusOfTeamMemberOverview(teamMemberId, archived, req.user?.id as string);
|
||||
const byProject = await this.getTasksByProjectOfTeamMemberOverview(teamMemberId, archived, req.user?.id as string);
|
||||
const byPriority = await this.getTasksByPriorityOfTeamMemberOverview(teamMemberId, archived, req.user?.id as string);
|
||||
|
||||
stats.projects = await this.getProjectCountOfTeamMember(teamMemberId, archived, req.user?.id as string);
|
||||
|
||||
const body = {
|
||||
stats,
|
||||
by_status: byStatus,
|
||||
by_project: byProject,
|
||||
by_priority: byPriority
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMemberOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamMemberId = req.query.teamMemberId as string;
|
||||
const { duration, date_range } = req.query;
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
let dateRange: string[] = [];
|
||||
if (typeof date_range === "string") {
|
||||
dateRange = date_range.split(",");
|
||||
}
|
||||
|
||||
const stats = await this.getMemberStats(teamMemberId, duration as string, dateRange, archived, req.user?.id as string);
|
||||
const byStatus = await this.getTasksByStatusOfTeamMember(teamMemberId, duration as string, dateRange, archived, req.user?.id as string);
|
||||
const byProject = await this.getTasksByProjectOfTeamMember(teamMemberId, duration as string, dateRange, archived, req.user?.id as string);
|
||||
const byPriority = await this.getTasksByPriorityOfTeamMember(teamMemberId, duration as string, dateRange, archived, req.user?.id as string);
|
||||
|
||||
stats.teams = await this.getTeamCountOfTeamMember(teamMemberId);
|
||||
stats.projects = await this.getProjectCountOfTeamMember(teamMemberId, archived, req.user?.id as string);
|
||||
|
||||
const body = {
|
||||
stats,
|
||||
by_status: byStatus,
|
||||
by_project: byProject,
|
||||
by_priority: byPriority
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMemberTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamMemberId = req.params.team_member_id?.trim() || null;
|
||||
const projectId = (req.query.project as string)?.trim() || null;
|
||||
const onlySingleMember = req.query.only_single_member as string;
|
||||
const { duration, date_range } = req.query;
|
||||
const includeArchived = req.query.archived === "true";
|
||||
|
||||
let dateRange: string[] = [];
|
||||
if (typeof date_range === "string") {
|
||||
dateRange = date_range.split(",");
|
||||
}
|
||||
|
||||
const results = await ReportingExportModel.getMemberTasks(teamMemberId as string, projectId, onlySingleMember, duration as string, dateRange, includeArchived, req.user?.id as string);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, results));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTeamOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teamId = req.params.team_id || null;
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const archivedClause = await this.getArchivedProjectsClause(archived, req.user?.id as string, "projects.id");
|
||||
|
||||
const byStatus = await this.getProjectsByStatus(teamId, archivedClause);
|
||||
const byCategory = await this.getProjectsByCategory(teamId, archivedClause);
|
||||
const byHealth = await this.getProjectsByHealth(teamId, archivedClause);
|
||||
|
||||
byCategory.all = byStatus.all;
|
||||
byHealth.all = byStatus.all;
|
||||
|
||||
const body = {
|
||||
by_status: byStatus,
|
||||
by_category: byCategory,
|
||||
by_health: byHealth
|
||||
};
|
||||
|
||||
this.createByProjectStatusChartData(body.by_status);
|
||||
this.createByProjectHealthChartData(body.by_health);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import HandleExceptions from "../../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../../interfaces/worklenz-response";
|
||||
import ReportingOverviewBase from "./reporting-overview-base";
|
||||
import { ReportingExportModel } from "../../../models/reporting-export";
|
||||
import { formatDuration, formatLogText, getColor, int } from "../../../shared/utils";
|
||||
import moment from "moment";
|
||||
import Excel from "exceljs";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA } from "../../../shared/constants";
|
||||
|
||||
export default class ReportingOverviewExportController extends ReportingOverviewBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"]);
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teamId = req.query.team as string;
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const teamFilterClause = `p.team_id = $1`;
|
||||
|
||||
const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField, sortOrder, "", "", "", archivedClause, teamFilterClause, "");
|
||||
|
||||
for (const project of result.projects) {
|
||||
project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
project.days_left = ReportingControllerBase.getDaysLeft(project.end_date);
|
||||
project.is_overdue = ReportingControllerBase.isOverdue(project.end_date);
|
||||
if (project.days_left && project.is_overdue) {
|
||||
project.days_left = project.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
project.is_today = this.isToday(project.end_date);
|
||||
project.estimated_time = int(project.estimated_time);
|
||||
project.actual_time = int(project.actual_time);
|
||||
project.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(project.estimated_time));
|
||||
project.actual_time_string = this.convertSecondsToHoursAndMinutes(int(project.actual_time));
|
||||
project.tasks_stat = {
|
||||
todo: this.getPercentage(int(project.tasks_stat.todo), +project.tasks_stat.total),
|
||||
doing: this.getPercentage(int(project.tasks_stat.doing), +project.tasks_stat.total),
|
||||
done: this.getPercentage(int(project.tasks_stat.done), +project.tasks_stat.total)
|
||||
};
|
||||
if (project.update.length > 0) {
|
||||
const update = project.update[0];
|
||||
const placeHolders = update.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < update.mentions.length) {
|
||||
update.content = update.content.replace(placeHolder, `
|
||||
<span class='mentions'> @${update.mentions[index].user_name} </span>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
project.comment = update.content;
|
||||
}
|
||||
if (project.last_activity) {
|
||||
if (project.last_activity.attribute_type === "estimation") {
|
||||
project.last_activity.previous = formatDuration(moment.duration(project.last_activity.previous, "minutes"));
|
||||
project.last_activity.current = formatDuration(moment.duration(project.last_activity.current, "minutes"));
|
||||
}
|
||||
if (project.last_activity.assigned_user) project.last_activity.assigned_user.color_code = getColor(project.last_activity.assigned_user.name);
|
||||
project.last_activity.done_by.color_code = getColor(project.last_activity.done_by.name);
|
||||
project.last_activity.log_text = await formatLogText(project.last_activity);
|
||||
project.last_activity.attribute_type = project.last_activity.attribute_type?.replace(/_/g, " ");
|
||||
project.last_activity.last_activity_string = `${project.last_activity.done_by.name} ${project.last_activity.log_text} ${project.last_activity.attribute_type}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectsByTeamOrMember(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teamId = (req.query.team_id as string)?.trim() || null;
|
||||
const teamName = (req.query.team_name as string)?.trim() || null;
|
||||
|
||||
const result = await ReportingControllerBase.exportProjects(teamId as string);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamName} projects - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Projects");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Project", key: "name", width: 30 },
|
||||
{ header: "Client", key: "client", width: 20 },
|
||||
{ header: "Category", key: "category", width: 20 },
|
||||
{ header: "Status", key: "status", width: 20 },
|
||||
{ header: "Start Date", key: "start_date", width: 20 },
|
||||
{ header: "End Date", key: "end_date", width: 20 },
|
||||
{ header: "Days Left/Overdue", key: "days_left", width: 20 },
|
||||
{ header: "Estimated Hours", key: "estimated_hours", width: 20 },
|
||||
{ header: "Actual Hours", key: "actual_hours", width: 20 },
|
||||
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
|
||||
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
||||
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 },
|
||||
{ header: "Last Activity", key: "last_activity", width: 20 },
|
||||
{ header: "Project Health", key: "project_health", width: 20 },
|
||||
{ header: "Project Update", key: "project_update", width: 20 }
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Projects from ${teamName}`;
|
||||
sheet.mergeCells("A1:O1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:O2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Project", "Client", "Category", "Status", "Start Date", "End Date", "Days Left/Overdue", "Estimated Hours", "Actual Hours", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)", "Last Activity", "Project Health", "Project Update"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of result.projects) {
|
||||
|
||||
if (item.is_overdue && item.days_left) {
|
||||
item.days_left = `-${item.days_left}`;
|
||||
}
|
||||
|
||||
if (item.is_today) {
|
||||
item.days_left = `Today`;
|
||||
}
|
||||
|
||||
sheet.addRow({
|
||||
name: item.name,
|
||||
client: item.client ? item.client : "-",
|
||||
category: item.category_name ? item.category_name : "-",
|
||||
status: item.status_name ? item.status_name : "-",
|
||||
start_date: item.start_date ? moment(item.start_date).format("YYYY-MM-DD") : "-",
|
||||
end_date: item.end_date ? moment(item.end_date).format("YYYY-MM-DD") : "-",
|
||||
days_left: item.days_left ? item.days_left.toString() : "-",
|
||||
estimated_hours: item.estimated_time ? item.estimated_time.toString() : "-",
|
||||
actual_hours: item.actual_time ? item.actual_time.toString() : "-",
|
||||
done_tasks: item.tasks_stat.done ? `${item.tasks_stat.done}` : "-",
|
||||
doing_tasks: item.tasks_stat.doing ? `${item.tasks_stat.doing}` : "-",
|
||||
todo_tasks: item.tasks_stat.todo ? `${item.tasks_stat.todo}` : "-",
|
||||
last_activity: item.last_activity ? item.last_activity.last_activity_string : "-",
|
||||
project_health: item.project_health,
|
||||
project_update: item.comment ? item.comment : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMembersByTeam(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teamId = (req.query.team_id as string)?.trim() || null;
|
||||
const teamName = (req.query.team_name as string)?.trim() || null;
|
||||
|
||||
const result = await ReportingExportModel.getMembersByTeam(teamId);
|
||||
|
||||
for (const member of result) {
|
||||
member.projects = int(member.projects);
|
||||
member.tasks = int(member.tasks);
|
||||
member.overdue = int(member.overdue);
|
||||
member.completed = int(member.completed);
|
||||
member.ongoing = int(member.ongoing);
|
||||
}
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamName} members - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Members");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Name", key: "name", width: 30 },
|
||||
{ header: "Email", key: "email", width: 20 },
|
||||
{ header: "Projects", key: "projects", width: 20 },
|
||||
{ header: "Tasks", key: "tasks", width: 20 },
|
||||
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
||||
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
||||
{ header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 },
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Members from ${teamName}`;
|
||||
sheet.mergeCells("A1:G1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:G2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Name", "Email", "Projects", "Tasks", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of result) {
|
||||
sheet.addRow({
|
||||
name: item.name,
|
||||
email: item.email ? item.email : "-",
|
||||
projects: item.projects ? item.projects.toString() : "-",
|
||||
tasks: item.tasks ? item.tasks.toString() : "-",
|
||||
overdue_tasks: item.overdue ? item.overdue.toString() : "-",
|
||||
completed_tasks: item.completed ? item.completed.toString() : "-",
|
||||
ongoing_tasks: item.ongoing ? item.ongoing.toString() : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportProjectMembers(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const projectId = (req.query.project_id as string)?.trim() || null;
|
||||
const projectName = (req.query.project_name as string)?.trim() || null;
|
||||
const teamName = (req.query.team_name as string)?.trim() || "";
|
||||
|
||||
const results = await ReportingExportModel.getProjectMembers(projectId as string);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamName} ${projectName} members - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Members");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Name", key: "name", width: 30 },
|
||||
{ header: "Tasks Count", key: "tasks_count", width: 20 },
|
||||
{ header: "Completed Tasks", key: "completed_tasks", width: 20 },
|
||||
{ header: "Incomplete Tasks", key: "incomplete_tasks", width: 20 },
|
||||
{ header: "Overdue Tasks", key: "overdue_tasks", width: 20 },
|
||||
{ header: "Contribution(%)", key: "contribution", width: 20 },
|
||||
{ header: "Progress(%)", key: "progress", width: 20 },
|
||||
{ header: "Logged Time", key: "logged_time", width: 20 },
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Members from ${projectName} - ${teamName}`;
|
||||
sheet.mergeCells("A1:H1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:H2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Name", "Tasks Count", "Completed Tasks", "Incomplete Tasks", "Overdue Tasks", "Contribution(%)", "Progress(%)", "Logged Time"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of results) {
|
||||
sheet.addRow({
|
||||
name: item.name,
|
||||
tasks_count: item.tasks_count ? item.tasks_count : "-",
|
||||
completed_tasks: item.completed ? item.completed : "-",
|
||||
incomplete_tasks: item.incompleted ? item.incompleted : "-",
|
||||
overdue_tasks: item.overdue ? item.overdue : "-",
|
||||
contribution: item.contribution ? item.contribution : "-",
|
||||
progress: item.progress ? item.progress : "-",
|
||||
logged_time: item.time_logged ? item.time_logged : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportProjectTasks(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const projectId = (req.query.project_id as string)?.trim() || null;
|
||||
const projectName = (req.query.project_name as string)?.trim() || null;
|
||||
const teamName = (req.query.team_name as string)?.trim() || "";
|
||||
|
||||
const results = await this.getAllTasks(projectId);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamName} ${projectName} tasks - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Tasks");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Task", key: "task", width: 30 },
|
||||
{ header: "Status", key: "status", width: 20 },
|
||||
{ header: "Priority", key: "priority", width: 20 },
|
||||
{ header: "Phase", key: "phase", width: 20 },
|
||||
{ header: "Due Date", key: "due_date", width: 20 },
|
||||
{ header: "Completed On", key: "completed_on", width: 20 },
|
||||
{ header: "Days Overdue", key: "days_overdue", width: 20 },
|
||||
{ header: "Estimated Time", key: "estimated_time", width: 20 },
|
||||
{ header: "Logged Time", key: "logged_time", width: 20 },
|
||||
{ header: "Overlogged Time", key: "overlogged_time", width: 20 },
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Tasks from ${projectName} - ${teamName}`;
|
||||
sheet.mergeCells("A1:J1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:J2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Task", "Status", "Priority", "Phase", "Due Date", "Completed On", "Days Overdue", "Estimated Time", "Logged Time", "Overlogged Time"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of results) {
|
||||
const time_spent = { hours: ~~(item.total_minutes_spent / 60), minutes: item.total_minutes_spent % 60 };
|
||||
item.total_minutes_spent = Math.ceil(item.total_seconds_spent / 60);
|
||||
|
||||
sheet.addRow({
|
||||
task: item.name,
|
||||
status: item.status_name ? item.status_name : "-",
|
||||
priority: item.priority_name ? item.priority_name : "-",
|
||||
phase: item.phase_name ? item.phase_name : "-",
|
||||
due_date: item.end_date ? moment(item.end_date).format("YYYY-MM-DD") : "-",
|
||||
completed_on: item.completed_at ? moment(item.completed_at).format("YYYY-MM-DD") : "-",
|
||||
days_overdue: item.overdue_days ? item.overdue_days : "-",
|
||||
estimated_time: item.total_minutes !== "0" ? `${~~(item.total_minutes / 60)}h ${(item.total_minutes % 60)}m` : "-",
|
||||
logged_time: item.total_minutes_spent ? `${time_spent.hours}h ${(time_spent.minutes)}m` : "-",
|
||||
overlogged_time: item.overlogged_time_string !== "0h 0m" ? item.overlogged_time_string : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportMemberTasks(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teamMemberId = (req.query.team_member_id as string)?.trim() || null;
|
||||
const teamMemberName = (req.query.team_member_name as string)?.trim() || null;
|
||||
const teamName = (req.query.team_name as string)?.trim() || "";
|
||||
|
||||
const { duration, date_range, only_single_member, archived} = req.query;
|
||||
|
||||
const includeArchived = req.query.archived === "true";
|
||||
|
||||
let dateRange: string[] = [];
|
||||
if (typeof date_range === "string") {
|
||||
dateRange = date_range.split(",");
|
||||
}
|
||||
|
||||
const results = await ReportingExportModel.getMemberTasks(teamMemberId as string, null, only_single_member as string, duration as string, dateRange, includeArchived, req.user?.id as string);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamMemberName} tasks - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Tasks");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Task", key: "task", width: 30 },
|
||||
{ header: "Project", key: "project", width: 20 },
|
||||
{ header: "Status", key: "status", width: 20 },
|
||||
{ header: "Priority", key: "priority", width: 20 },
|
||||
{ header: "Due Date", key: "due_date", width: 20 },
|
||||
{ header: "Completed Date", key: "completed_on", width: 20 },
|
||||
{ header: "Estimated Time", key: "estimated_time", width: 20 },
|
||||
{ header: "Logged Time", key: "logged_time", width: 20 },
|
||||
{ header: "Overlogged Time", key: "overlogged_time", width: 20 },
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Tasks of ${teamMemberName} - ${teamName}`;
|
||||
sheet.mergeCells("A1:I1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:I2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Task", "Project", "Status", "Priority", "Due Date", "Completed Date", "Estimated Time", "Logged Time", "Overlogged Time"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of results) {
|
||||
sheet.addRow({
|
||||
task: item.name,
|
||||
project: item.project_name ? item.project_name : "-",
|
||||
status: item.status_name ? item.status_name : "-",
|
||||
priority: item.priority_name ? item.priority_name : "-",
|
||||
due_date: item.end_date ? moment(item.end_date).format("YYYY-MM-DD") : "-",
|
||||
completed_on: item.completed_date ? moment(item.completed_date).format("YYYY-MM-DD") : "-",
|
||||
estimated_time: item.estimated_string ? item.estimated_string : "-",
|
||||
logged_time: item.time_spent_string ? item.time_spent_string : "-",
|
||||
overlogged_time: item.overlogged_time ? item.overlogged_time : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportFlatTasks(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teamMemberId = (req.query.team_member_id as string)?.trim() || null;
|
||||
const teamMemberName = (req.query.team_member_name as string)?.trim() || null;
|
||||
const projectId = (req.query.project_id as string)?.trim() || null;
|
||||
const projectName = (req.query.project_name as string)?.trim() || null;
|
||||
|
||||
const includeArchived = req.query.archived === "true";
|
||||
|
||||
const results = await ReportingExportModel.getMemberTasks(teamMemberId as string, projectId, "false", "", [], includeArchived, req.user?.id as string);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamMemberName}'s tasks in ${projectName} - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Tasks");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Task", key: "task", width: 30 },
|
||||
{ header: "Project", key: "project", width: 20 },
|
||||
{ header: "Status", key: "status", width: 20 },
|
||||
{ header: "Priority", key: "priority", width: 20 },
|
||||
{ header: "Due Date", key: "due_date", width: 20 },
|
||||
{ header: "Completed Date", key: "completed_on", width: 20 },
|
||||
{ header: "Estimated Time", key: "estimated_time", width: 20 },
|
||||
{ header: "Logged Time", key: "logged_time", width: 20 },
|
||||
{ header: "Overlogged Time", key: "overlogged_time", width: 20 },
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Tasks of ${teamMemberName} in ${projectName}`;
|
||||
sheet.mergeCells("A1:I1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:I2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Task", "Project", "Status", "Priority", "Due Date", "Completed Date", "Estimated Time", "Logged Time", "Overlogged Time"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of results) {
|
||||
sheet.addRow({
|
||||
task: item.name,
|
||||
project: item.project_name ? item.project_name : "-",
|
||||
status: item.status_name ? item.status_name : "-",
|
||||
priority: item.priority_name ? item.priority_name : "-",
|
||||
due_date: item.end_date ? moment(item.end_date).format("YYYY-MM-DD") : "-",
|
||||
completed_on: item.completed_date ? moment(item.completed_date).format("YYYY-MM-DD") : "-",
|
||||
estimated_time: item.estimated_string ? item.estimated_string : "-",
|
||||
logged_time: item.time_spent_string ? item.time_spent_string : "-",
|
||||
overlogged_time: item.overlogged_time ? item.overlogged_time : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
|
||||
export default class ReportingProjectsBase extends ReportingControllerBase {
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import HandleExceptions from "../../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../../models/server-response";
|
||||
import ReportingProjectsBase from "./reporting-projects-base";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
import moment from "moment";
|
||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../../shared/constants";
|
||||
import { getColor, int, formatDuration, formatLogText } from "../../../shared/utils";
|
||||
import db from "../../../config/db";
|
||||
|
||||
export default class ReportingProjectsController extends ReportingProjectsBase {
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(",").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"]);
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teamId = this.getCurrentTeamId(req);
|
||||
|
||||
const statusesClause = req.query.statuses as string
|
||||
? `AND p.status_id IN (${this.flatString(req.query.statuses as string)})`
|
||||
: "";
|
||||
|
||||
const healthsClause = req.query.healths as string
|
||||
? `AND p.health_id IN (${this.flatString(req.query.healths as string)})`
|
||||
: "";
|
||||
|
||||
const categoriesClause = req.query.categories as string
|
||||
? `AND p.category_id IN (${this.flatString(req.query.categories as string)})`
|
||||
: "";
|
||||
|
||||
// const projectManagersClause = req.query.project_managers as string
|
||||
// ? `AND p.id IN (SELECT project_id from project_members WHERE team_member_id IN (${this.flatString(req.query.project_managers as string)}) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))`
|
||||
// : "";
|
||||
|
||||
const projectManagersClause = req.query.project_managers as string
|
||||
? `AND p.id IN(SELECT project_id FROM project_members WHERE team_member_id IN(SELECT id FROM team_members WHERE user_id IN (${this.flatString(req.query.project_managers as string)})) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))`
|
||||
: "";
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const teamFilterClause = `in_organization(p.team_id, $1)`;
|
||||
|
||||
const result = await ReportingControllerBase.getProjectsByTeam(teamId as string, size, offset, searchQuery, sortField, sortOrder, statusesClause, healthsClause, categoriesClause, archivedClause, teamFilterClause, projectManagersClause);
|
||||
|
||||
for (const project of result.projects) {
|
||||
project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
project.days_left = ReportingControllerBase.getDaysLeft(project.end_date);
|
||||
project.is_overdue = ReportingControllerBase.isOverdue(project.end_date);
|
||||
if (project.days_left && project.is_overdue) {
|
||||
project.days_left = project.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
project.is_today = this.isToday(project.end_date);
|
||||
project.estimated_time = int(project.estimated_time);
|
||||
project.actual_time = int(project.actual_time);
|
||||
project.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(project.estimated_time));
|
||||
project.actual_time_string = this.convertSecondsToHoursAndMinutes(int(project.actual_time));
|
||||
project.tasks_stat = {
|
||||
todo: this.getPercentage(int(project.tasks_stat.todo), +project.tasks_stat.total),
|
||||
doing: this.getPercentage(int(project.tasks_stat.doing), +project.tasks_stat.total),
|
||||
done: this.getPercentage(int(project.tasks_stat.done), +project.tasks_stat.total)
|
||||
};
|
||||
if (project.update.length > 0) {
|
||||
const [update] = project.update;
|
||||
const placeHolders = update.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < update.mentions.length) {
|
||||
update.content = update.content.replace(placeHolder, `
|
||||
<span class='mentions'> @${update.mentions[index].user_name} </span>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
project.comment = update.content;
|
||||
}
|
||||
if (project.last_activity) {
|
||||
if (project.last_activity.attribute_type === "estimation") {
|
||||
project.last_activity.previous = formatDuration(moment.duration(project.last_activity.previous, "minutes"));
|
||||
project.last_activity.current = formatDuration(moment.duration(project.last_activity.current, "minutes"));
|
||||
}
|
||||
if (project.last_activity.assigned_user) project.last_activity.assigned_user.color_code = getColor(project.last_activity.assigned_user.name);
|
||||
project.last_activity.done_by.color_code = getColor(project.last_activity.done_by.name);
|
||||
project.last_activity.log_text = await formatLogText(project.last_activity);
|
||||
project.last_activity.attribute_type = project.last_activity.attribute_type?.replace(/_/g, " ");
|
||||
project.last_activity.last_activity_string = `${project.last_activity.done_by.name} ${project.last_activity.log_text} ${project.last_activity.attribute_type}`;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
protected static getMinMaxDates(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `,(SELECT '${start}'::DATE )AS start_date, (SELECT '${end}'::DATE )AS end_date`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.ALL_TIME)
|
||||
return ",(SELECT (MIN(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS start_date, (SELECT (MAX(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS end_date";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectTimeLogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.body.id;
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
|
||||
const q = `SELECT
|
||||
(SELECT name FROM projects WHERE projects.id = $1) AS project_name,
|
||||
(SELECT key FROM projects WHERE projects.id = $1) AS project_key,
|
||||
(SELECT task_no FROM tasks WHERE tasks.id = task_work_log.task_id) AS task_key_num,
|
||||
(SELECT name FROM tasks WHERE tasks.id = task_work_log.task_id) AS task_name,
|
||||
task_work_log.time_spent,
|
||||
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url,
|
||||
task_work_log.created_at
|
||||
${minMaxDateClause}
|
||||
FROM task_work_log
|
||||
WHERE
|
||||
task_id IN (select id from tasks WHERE project_id = $1)
|
||||
${durationClause}
|
||||
ORDER BY task_work_log.created_at DESC`;
|
||||
|
||||
const result = await db.query(q, [projectId]);
|
||||
|
||||
const formattedResult = await this.formatLog(result.rows);
|
||||
|
||||
const logGroups = await this.getTimeLogDays(formattedResult);
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, logGroups));
|
||||
}
|
||||
|
||||
private static async formatLog(result: any[]) {
|
||||
|
||||
result.forEach((row) => {
|
||||
const duration = moment.duration(row.time_spent, "seconds");
|
||||
row.time_spent_string = this.formatDuration(duration);
|
||||
row.task_key = `${row.project_key}-${row.task_key_num}`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async getTimeLogDays(result: any[]) {
|
||||
if (result.length) {
|
||||
const startDate = moment(result[0].start_date).isValid() ? moment(result[0].start_date, "YYYY-MM-DD").clone() : null;
|
||||
const endDate = moment(result[0].end_date).isValid() ? moment(result[0].end_date, "YYYY-MM-DD").clone() : null;
|
||||
|
||||
const days = [];
|
||||
const logDayGroups = [];
|
||||
|
||||
while (startDate && moment(startDate).isSameOrBefore(endDate)) {
|
||||
days.push(startDate.clone().format("YYYY-MM-DD"));
|
||||
startDate ? startDate.add(1, "day") : null;
|
||||
}
|
||||
|
||||
for (const day of days) {
|
||||
const logsForDay = result.filter((log) => moment(moment(log.created_at).format("YYYY-MM-DD")).isSame(moment(day).format("YYYY-MM-DD")));
|
||||
if (logsForDay.length) {
|
||||
logDayGroups.push({
|
||||
log_day: day,
|
||||
logs: logsForDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return logDayGroups;
|
||||
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static formatDuration(duration: moment.Duration) {
|
||||
const empty = "0h 0m";
|
||||
let format = "";
|
||||
|
||||
if (duration.asMilliseconds() === 0) return empty;
|
||||
|
||||
const h = ~~(duration.asHours());
|
||||
const m = duration.minutes();
|
||||
const s = duration.seconds();
|
||||
|
||||
if (h === 0 && s > 0) {
|
||||
format = `${m}m ${s}s`;
|
||||
} else if (h > 0 && s === 0) {
|
||||
format = `${h}h ${m}m`;
|
||||
} else if (h > 0 && s > 0) {
|
||||
format = `${h}h ${m}m ${s}s`;
|
||||
} else {
|
||||
format = `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import moment from "moment";
|
||||
import HandleExceptions from "../../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../../interfaces/worklenz-response";
|
||||
import ReportingProjectsBase from "./reporting-projects-base";
|
||||
import Excel from "exceljs";
|
||||
import ReportingControllerBase from "../reporting-controller-base";
|
||||
import { DATE_RANGES } from "../../../shared/constants";
|
||||
import db from "../../../config/db";
|
||||
|
||||
export default class ReportingProjectsExportController extends ReportingProjectsBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async export(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teamId = this.getCurrentTeamId(req);
|
||||
const teamName = (req.query.team_name as string)?.trim() || null;
|
||||
|
||||
const results = await ReportingControllerBase.exportProjectsAll(teamId as string);
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${teamName} projects - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Projects");
|
||||
|
||||
// define columns in table
|
||||
sheet.columns = [
|
||||
{ header: "Project", key: "name", width: 30 },
|
||||
{ header: "Client", key: "client", width: 20 },
|
||||
{ header: "Category", key: "category", width: 20 },
|
||||
{ header: "Status", key: "status", width: 20 },
|
||||
{ header: "Start Date", key: "start_date", width: 20 },
|
||||
{ header: "End Date", key: "end_date", width: 20 },
|
||||
{ header: "Days Left/Overdue", key: "days_left", width: 20 },
|
||||
{ header: "Estimated Hours", key: "estimated_hours", width: 20 },
|
||||
{ header: "Actual Hours", key: "actual_hours", width: 20 },
|
||||
{ header: "Done Tasks(%)", key: "done_tasks", width: 20 },
|
||||
{ header: "Doing Tasks(%)", key: "doing_tasks", width: 20 },
|
||||
{ header: "Todo Tasks(%)", key: "todo_tasks", width: 20 },
|
||||
{ header: "Last Activity", key: "last_activity", width: 20 },
|
||||
{ header: "Project Health", key: "project_health", width: 20 },
|
||||
{ header: "Project Update", key: "project_update", width: 20 }
|
||||
];
|
||||
|
||||
// set title
|
||||
sheet.getCell("A1").value = `Projects from ${teamName}`;
|
||||
sheet.mergeCells("A1:O1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
// set export date
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:O2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
// const start = 'duartion start';
|
||||
// const end = 'duartion end';
|
||||
// sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
// sheet.mergeCells("A3:D3");
|
||||
|
||||
// set table headers
|
||||
sheet.getRow(4).values = ["Project", "Client", "Category", "Status", "Start Date", "End Date", "Days Left/Overdue", "Estimated Hours", "Actual Hours", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)", "Last Activity", "Project Health", "Project Update"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
// set table data
|
||||
for (const item of results.projects) {
|
||||
|
||||
if (item.is_overdue && item.days_left) {
|
||||
item.days_left = `-${item.days_left}`;
|
||||
}
|
||||
|
||||
if (item.is_today) {
|
||||
item.days_left = `Today`;
|
||||
}
|
||||
|
||||
sheet.addRow({
|
||||
name: item.name,
|
||||
client: item.client ? item.client : "-",
|
||||
category: item.category_name ? item.category_name : "-",
|
||||
status: item.status_name ? item.status_name : "-",
|
||||
start_date: item.start_date ? moment(item.start_date).format("YYYY-MM-DD") : "-",
|
||||
end_date: item.end_date ? moment(item.end_date).format("YYYY-MM-DD") : "-",
|
||||
days_left: item.days_left ? item.days_left.toString() : "-",
|
||||
estimated_hours: item.estimated_time ? item.estimated_time.toString() : "-",
|
||||
actual_hours: item.actual_time ? item.actual_time.toString() : "-",
|
||||
done_tasks: item.tasks_stat.done ? `${item.tasks_stat.done}` : "-",
|
||||
doing_tasks: item.tasks_stat.doing ? `${item.tasks_stat.doing}` : "-",
|
||||
todo_tasks: item.tasks_stat.todo ? `${item.tasks_stat.todo}` : "-",
|
||||
last_activity: item.last_activity ? item.last_activity.last_activity_string : "-",
|
||||
project_health: item.project_health,
|
||||
project_update: item.comment ? item.comment : "-",
|
||||
});
|
||||
}
|
||||
|
||||
// download excel
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async exportProjectTimeLogs(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const result = await this.getProjectTimeLogs(req);
|
||||
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `${req.query.project_name} Time Logs - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Time Logs");
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "Date", key: "date", width: 30 },
|
||||
{ header: "Log", key: "log", width: 120 },
|
||||
];
|
||||
|
||||
sheet.getCell("A1").value = `Time Logs from ${req.query.project_name}`;
|
||||
sheet.mergeCells("A1:O1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:O2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
sheet.getRow(4).values = ["Date", "Log"];
|
||||
sheet.getRow(4).font = { bold: true };
|
||||
|
||||
for (const row of result) {
|
||||
for (const log of row.logs) {
|
||||
sheet.addRow({
|
||||
date: row.log_day,
|
||||
log: `${log.user_name} logged ${log.time_spent_string} for ${log.task_name}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private static async getProjectTimeLogs(req: IWorkLenzRequest) {
|
||||
const projectId = req.query.id;
|
||||
const duration = req.query.duration as string;
|
||||
const date_range = req.query.date_range as [];
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
|
||||
const q = `SELECT
|
||||
(SELECT name FROM projects WHERE projects.id = $1) AS project_name,
|
||||
(SELECT key FROM projects WHERE projects.id = $1) AS project_key,
|
||||
(SELECT task_no FROM tasks WHERE tasks.id = task_work_log.task_id) AS task_key_num,
|
||||
(SELECT name FROM tasks WHERE tasks.id = task_work_log.task_id) AS task_name,
|
||||
task_work_log.time_spent,
|
||||
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url,
|
||||
task_work_log.created_at
|
||||
${minMaxDateClause}
|
||||
FROM task_work_log
|
||||
WHERE
|
||||
task_id IN (select id from tasks WHERE project_id = $1)
|
||||
${durationClause}
|
||||
ORDER BY task_work_log.created_at DESC`;
|
||||
|
||||
const result = await db.query(q, [projectId]);
|
||||
|
||||
const formattedResult = await this.formatLog(result.rows);
|
||||
|
||||
const logGroups = await this.getTimeLogDays(formattedResult);
|
||||
|
||||
return logGroups;
|
||||
}
|
||||
|
||||
|
||||
protected static getMinMaxDates(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
return `,(SELECT '${start}'::DATE )AS start_date, (SELECT '${end}'::DATE )AS end_date`;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return ",(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date";
|
||||
if (key === DATE_RANGES.ALL_TIME)
|
||||
return ",(SELECT (MIN(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS start_date, (SELECT (MAX(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS end_date";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static async formatLog(result: any[]) {
|
||||
|
||||
result.forEach((row) => {
|
||||
const duration = moment.duration(row.time_spent, "seconds");
|
||||
row.time_spent_string = this.formatDuration(duration);
|
||||
row.task_key = `${row.project_key}-${row.task_key_num}`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async getTimeLogDays(result: any[]) {
|
||||
if (result.length) {
|
||||
const startDate = moment(result[0].start_date).isValid() ? moment(result[0].start_date, "YYYY-MM-DD").clone() : null;
|
||||
const endDate = moment(result[0].end_date).isValid() ? moment(result[0].end_date, "YYYY-MM-DD").clone() : null;
|
||||
|
||||
const days = [];
|
||||
const logDayGroups = [];
|
||||
|
||||
while (startDate && moment(startDate).isSameOrBefore(endDate)) {
|
||||
days.push(startDate.clone().format("YYYY-MM-DD"));
|
||||
startDate ? startDate.add(1, "day") : null;
|
||||
}
|
||||
|
||||
for (const day of days) {
|
||||
const logsForDay = result.filter((log) => moment(moment(log.created_at).format("YYYY-MM-DD")).isSame(moment(day).format("YYYY-MM-DD")));
|
||||
if (logsForDay.length) {
|
||||
logDayGroups.push({
|
||||
log_day: day,
|
||||
logs: logsForDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return logDayGroups;
|
||||
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static formatDuration(duration: moment.Duration) {
|
||||
const empty = "0h 0m";
|
||||
let format = "";
|
||||
|
||||
if (duration.asMilliseconds() === 0) return empty;
|
||||
|
||||
const h = ~~(duration.asHours());
|
||||
const m = duration.minutes();
|
||||
const s = duration.seconds();
|
||||
|
||||
if (h === 0 && s > 0) {
|
||||
format = `${m}m ${s}s`;
|
||||
} else if (h > 0 && s === 0) {
|
||||
format = `${h}h ${m}m`;
|
||||
} else if (h > 0 && s > 0) {
|
||||
format = `${h}h ${m}m ${s}s`;
|
||||
} else {
|
||||
format = `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import moment from "moment";
|
||||
import db from "../../config/db";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { getColor, int, log_error } from "../../shared/utils";
|
||||
import ReportingControllerBase from "./reporting-controller-base";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
import Excel from "exceljs";
|
||||
|
||||
enum IToggleOptions {
|
||||
'WORKING_DAYS' = 'WORKING_DAYS', 'MAN_DAYS' = 'MAN_DAYS'
|
||||
}
|
||||
|
||||
export default class ReportingAllocationController extends ReportingControllerBase {
|
||||
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = ""): Promise<any> {
|
||||
try {
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const userIds = users.map(u => `'${u}'`).join(",");
|
||||
|
||||
const duration = this.getDateRangeClause(key || DATE_RANGES.LAST_WEEK, dateRange);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${user_id}') `;
|
||||
|
||||
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause);
|
||||
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds);
|
||||
|
||||
const format = (seconds: number) => {
|
||||
if (seconds === 0) return "-";
|
||||
const duration = moment.duration(seconds, "seconds");
|
||||
const formattedDuration = `${~~(duration.asHours())}h ${duration.minutes()}m ${duration.seconds()}s`;
|
||||
return formattedDuration;
|
||||
};
|
||||
|
||||
let totalProjectsTime = 0;
|
||||
let totalUsersTime = 0;
|
||||
|
||||
for (const project of projectTimeLogs) {
|
||||
if (project.all_tasks_count > 0) {
|
||||
project.progress = Math.round((project.completed_tasks_count / project.all_tasks_count) * 100);
|
||||
} else {
|
||||
project.progress = 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const log of project.time_logs) {
|
||||
total += log.time_logged;
|
||||
log.time_logged = format(log.time_logged);
|
||||
}
|
||||
project.totalProjectsTime = totalProjectsTime + total;
|
||||
project.total = format(total);
|
||||
}
|
||||
|
||||
for (const log of userTimeLogs) {
|
||||
log.totalUsersTime = totalUsersTime + parseInt(log.time_logged)
|
||||
log.time_logged = format(parseInt(log.time_logged));
|
||||
}
|
||||
|
||||
return { projectTimeLogs, userTimeLogs };
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "") {
|
||||
try {
|
||||
const q = `SELECT projects.name,
|
||||
projects.color_code,
|
||||
sps.name AS status_name,
|
||||
sps.color_code AS status_color_code,
|
||||
sps.icon AS status_icon,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
AND project_id = projects.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
|
||||
AND project_id = projects.id
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE project_id = projects.id
|
||||
AND category_id IN
|
||||
(SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
(
|
||||
SELECT COALESCE(JSON_AGG(r), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT name,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks t ON task_work_log.task_id = t.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
|
||||
AND t.project_id = projects.id
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
ORDER BY name
|
||||
) r
|
||||
) AS time_logs
|
||||
FROM projects
|
||||
LEFT JOIN sys_project_statuses sps ON projects.status_id = sps.id
|
||||
WHERE projects.id IN (${projectIds}) ${archivedClause};`;
|
||||
|
||||
const result = await db.query(q, [archived]);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string) {
|
||||
try {
|
||||
const q = `(SELECT id,
|
||||
(SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
LEFT JOIN tasks t ON task_work_log.task_id = t.id
|
||||
WHERE user_id = users.id
|
||||
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
|
||||
AND t.project_id IN (${projectIds})
|
||||
${duration}) AS time_logged
|
||||
FROM users
|
||||
WHERE id IN (${userIds})
|
||||
ORDER BY name);`;
|
||||
const result = await db.query(q, [archived]);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async getUserIds(teamIds: any) {
|
||||
try {
|
||||
const q = `SELECT id, (SELECT name)
|
||||
FROM users
|
||||
WHERE id IN (SELECT user_id
|
||||
FROM team_members
|
||||
WHERE team_id IN (${teamIds}))
|
||||
GROUP BY id
|
||||
ORDER BY name`;
|
||||
const result = await db.query(q, []);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getAllocation(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const teams = (req.body.teams || []) as string[]; // ids
|
||||
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
const projectIds = (req.body.projects || []) as string[];
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const users = await this.getUserIds(teamIds);
|
||||
const userIds = users.map((u: any) => u.id);
|
||||
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id);
|
||||
|
||||
for (const [i, user] of users.entries()) {
|
||||
user.total_time = userTimeLogs[i].time_logged;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { users, projects: projectTimeLogs }));
|
||||
}
|
||||
|
||||
public static formatDurationDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
@HandleExceptions()
|
||||
public static async export(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const teams = (req.query.teams as string)?.split(",");
|
||||
const teamIds = teams.map(t => `'${t}'`).join(",");
|
||||
|
||||
const projectIds = (req.query.projects as string)?.split(",");
|
||||
|
||||
const duration = req.query.duration;
|
||||
|
||||
const dateRange = (req.query.date_range as string)?.split(",");
|
||||
|
||||
let start = "-";
|
||||
let end = "-";
|
||||
|
||||
if (dateRange.length === 2) {
|
||||
start = dateRange[0] ? this.formatDurationDate(new Date(dateRange[0])).toString() : "-";
|
||||
end = dateRange[1] ? this.formatDurationDate(new Date(dateRange[1])).toString() : "-";
|
||||
} else {
|
||||
switch (duration) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
start = moment().subtract(1, "day").format("YYYY-MM-DD").toString();
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
start = moment().subtract(1, "week").format("YYYY-MM-DD").toString();
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
start = moment().subtract(1, "month").format("YYYY-MM-DD").toString();
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
start = moment().subtract(3, "months").format("YYYY-MM-DD").toString();
|
||||
break;
|
||||
}
|
||||
end = moment().format("YYYY-MM-DD").toString();
|
||||
}
|
||||
|
||||
const users = await this.getUserIds(teamIds);
|
||||
const userIds = users.map((u: any) => u.id);
|
||||
|
||||
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id);
|
||||
|
||||
for (const [i, user] of users.entries()) {
|
||||
user.total_time = userTimeLogs[i].time_logged;
|
||||
}
|
||||
|
||||
// excel file
|
||||
const exportDate = moment().format("MMM-DD-YYYY");
|
||||
const fileName = `Reporting Time Sheet - ${exportDate}`;
|
||||
const workbook = new Excel.Workbook();
|
||||
|
||||
const sheet = workbook.addWorksheet("Reporting Time Sheet");
|
||||
|
||||
sheet.columns = [
|
||||
{ header: "Project", key: "project", width: 25 },
|
||||
{ header: "Logged Time", key: "logged_time", width: 20 },
|
||||
{ header: "Total", key: "total", width: 25 },
|
||||
];
|
||||
|
||||
sheet.getCell("A1").value = `Reporting Time Sheet`;
|
||||
sheet.mergeCells("A1:G1");
|
||||
sheet.getCell("A1").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A1").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "D9D9D9" } };
|
||||
sheet.getCell("A1").font = { size: 16 };
|
||||
|
||||
sheet.getCell("A2").value = `Exported on : ${exportDate}`;
|
||||
sheet.mergeCells("A2:G2");
|
||||
sheet.getCell("A2").alignment = { horizontal: "center" };
|
||||
sheet.getCell("A2").style.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "F2F2F2" } };
|
||||
sheet.getCell("A2").font = { size: 12 };
|
||||
|
||||
// set duration
|
||||
sheet.getCell("A3").value = `From : ${start} To : ${end}`;
|
||||
sheet.mergeCells("A3:D3");
|
||||
|
||||
let totalProjectTime = 0;
|
||||
let totalMemberTime = 0;
|
||||
|
||||
if (projectTimeLogs.length > 0) {
|
||||
const rowTop = sheet.getRow(5);
|
||||
rowTop.getCell(1).value = "";
|
||||
|
||||
users.forEach((user: { id: string, name: string, total_time: string }, index: any) => {
|
||||
rowTop.getCell(index + 2).value = user.name;
|
||||
});
|
||||
|
||||
rowTop.getCell(users.length + 2).value = "Total";
|
||||
|
||||
rowTop.font = {
|
||||
bold: true
|
||||
};
|
||||
|
||||
for (const project of projectTimeLogs) {
|
||||
|
||||
const rowValues = [];
|
||||
rowValues[1] = project.name;
|
||||
project.time_logs.forEach((log: any, index: any) => {
|
||||
rowValues[index + 2] = log.time_logged === "0h 0m 0s" ? "-" : log.time_logged;
|
||||
});
|
||||
rowValues[project.time_logs.length + 2] = project.total;
|
||||
sheet.addRow(rowValues);
|
||||
|
||||
const { lastRow } = sheet;
|
||||
if (lastRow) {
|
||||
const totalCell = lastRow.getCell(project.time_logs.length + 2);
|
||||
totalCell.style.font = { bold: true };
|
||||
}
|
||||
totalProjectTime = totalProjectTime + project.totalProjectsTime
|
||||
}
|
||||
|
||||
const rowBottom = sheet.getRow(projectTimeLogs.length + 6);
|
||||
rowBottom.getCell(1).value = "Total";
|
||||
rowBottom.getCell(1).style.font = { bold: true };
|
||||
userTimeLogs.forEach((log: { id: string, time_logged: string, totalUsersTime: number }, index: any) => {
|
||||
totalMemberTime = totalMemberTime + log.totalUsersTime
|
||||
rowBottom.getCell(index + 2).value = log.time_logged;
|
||||
});
|
||||
rowBottom.font = {
|
||||
bold: true
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
const format = (seconds: number) => {
|
||||
if (seconds === 0) return "-";
|
||||
const duration = moment.duration(seconds, "seconds");
|
||||
const formattedDuration = `${~~(duration.asHours())}h ${duration.minutes()}m ${duration.seconds()}s`;
|
||||
return formattedDuration;
|
||||
};
|
||||
|
||||
const projectTotalTimeRow = sheet.getRow(projectTimeLogs.length + 8);
|
||||
projectTotalTimeRow.getCell(1).value = "Total logged time of Projects"
|
||||
projectTotalTimeRow.getCell(2).value = `${format(totalProjectTime)}`
|
||||
projectTotalTimeRow.getCell(1).style.font = { bold: true };
|
||||
projectTotalTimeRow.getCell(2).style.font = { bold: true };
|
||||
|
||||
const membersTotalTimeRow = sheet.getRow(projectTimeLogs.length + 9);
|
||||
membersTotalTimeRow.getCell(1).value = "Total logged time of Members"
|
||||
membersTotalTimeRow.getCell(2).value = `${format(totalMemberTime)}`
|
||||
membersTotalTimeRow.getCell(1).style.font = { bold: true };
|
||||
membersTotalTimeRow.getCell(2).style.font = { bold: true };
|
||||
|
||||
res.setHeader("Content-Type", "application/vnd.openxmlformats");
|
||||
res.setHeader("Content-Disposition", `attachment; filename=${fileName}.xlsx`);
|
||||
|
||||
await workbook.xlsx.write(res)
|
||||
.then(() => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjectTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teams = (req.body.teams || []) as string[]; // ids
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
(SELECT SUM(time_spent)) AS logged_time,
|
||||
SUM(total_minutes) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
const data = [];
|
||||
|
||||
for (const project of result.rows) {
|
||||
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
|
||||
|
||||
if (project.value > 0 ) {
|
||||
data.push(project);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teams = (req.body.teams || []) as string[]; // ids
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const q = `
|
||||
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
|
||||
FROM team_member_info_view tmiv
|
||||
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
|
||||
LEFT JOIN tasks t ON t.id = task_work_log.task_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id AND p.team_id = tmiv.team_id
|
||||
WHERE p.id IN (${projectIds})
|
||||
${durationClause} ${archivedClause}
|
||||
GROUP BY tmiv.email, tmiv.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
for (const member of result.rows) {
|
||||
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
|
||||
member.color_code = getColor(member.name);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
private static getEstimated(project: any, type: string) {
|
||||
|
||||
switch (type) {
|
||||
case IToggleOptions.MAN_DAYS:
|
||||
return project.estimated_man_days ?? 0;;
|
||||
|
||||
case IToggleOptions.WORKING_DAYS:
|
||||
return project.estimated_working_days ?? 0;;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getEstimatedVsActual(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const archived = req.query.archived === "true";
|
||||
|
||||
const teams = (req.body.teams || []) as string[]; // ids
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const { type } = req.body;
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
|
||||
|
||||
const q = `
|
||||
SELECT p.id,
|
||||
p.name,
|
||||
p.end_date,
|
||||
p.hours_per_day::INT,
|
||||
p.estimated_man_days::INT,
|
||||
p.estimated_working_days::INT,
|
||||
(SELECT SUM(time_spent)) AS logged_time,
|
||||
(SELECT COALESCE(SUM(total_minutes), 0)
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS estimated,
|
||||
color_code
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
|
||||
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY logged_time DESC;`;
|
||||
const result = await db.query(q, []);
|
||||
|
||||
const data = [];
|
||||
|
||||
for (const project of result.rows) {
|
||||
const durationInHours = parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2));
|
||||
const hoursPerDay = parseInt(project.hours_per_day ?? 1);
|
||||
|
||||
project.value = parseFloat((durationInHours / hoursPerDay).toFixed(2)) ?? 0;
|
||||
|
||||
project.estimated_value = this.getEstimated(project, type);
|
||||
project.estimated_man_days = project.estimated_man_days ?? 0;
|
||||
project.estimated_working_days = project.estimated_working_days ?? 0;
|
||||
project.hours_per_day = project.hours_per_day ?? 0;
|
||||
|
||||
if (project.value > 0 || project.estimated_value > 0 ) {
|
||||
data.push(project);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import db from "../../config/db";
|
||||
import moment from "moment";
|
||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { formatDuration, formatLogText, getColor, int } from "../../shared/utils";
|
||||
|
||||
export default abstract class ReportingControllerBase extends WorklenzControllerBase {
|
||||
protected static getPercentage(n: number, total: number) {
|
||||
return +(n ? (n / total) * 100 : 0).toFixed();
|
||||
}
|
||||
|
||||
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
|
||||
return req.user?.team_id ?? null;
|
||||
}
|
||||
|
||||
protected static async getTotalTasksCount(projectId: string | null) {
|
||||
const q = `
|
||||
SELECT COUNT(*) AS count
|
||||
FROM tasks
|
||||
WHERE project_id = $1;
|
||||
`;
|
||||
const result = await db.query(q, [projectId]);
|
||||
const [data] = result.rows;
|
||||
return data.count || 0;
|
||||
}
|
||||
|
||||
protected static async getArchivedProjectsClause(archived = false, user_id: string, column_name: string) {
|
||||
return archived
|
||||
? ""
|
||||
: `AND ${column_name} NOT IN (SELECT project_id FROM archived_projects WHERE project_id = ${column_name} AND user_id = '${user_id}') `;
|
||||
}
|
||||
|
||||
protected static async getAllTasks(projectId: string | null) {
|
||||
const q = `
|
||||
SELECT id,
|
||||
name,
|
||||
parent_task_id,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
status_id AS status,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = status_id)) AS status_color,
|
||||
priority_id AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = tasks.priority_id) AS priority_value,
|
||||
(SELECT name FROM task_priorities WHERE id = tasks.priority_id) AS priority_name,
|
||||
(SELECT color_code FROM task_priorities WHERE id = tasks.priority_id) AS priority_color,
|
||||
end_date,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
(SELECT name
|
||||
FROM project_phases
|
||||
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = tasks.id)) AS phase_name,
|
||||
completed_at,
|
||||
total_minutes,
|
||||
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = tasks.id) AS total_seconds_spent
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
`;
|
||||
const result = await db.query(q, [projectId]);
|
||||
|
||||
for (const item of result.rows) {
|
||||
const endDate = moment(item.end_date);
|
||||
const completedDate = moment(item.completed_at);
|
||||
const overdueDays = completedDate.diff(endDate, "days");
|
||||
|
||||
if (overdueDays > 0) {
|
||||
item.overdue_days = overdueDays.toString();
|
||||
} else {
|
||||
item.overdue_days = "0";
|
||||
}
|
||||
|
||||
item.total_minutes_spent = Math.ceil(item.total_seconds_spent / 60);
|
||||
|
||||
if (~~(item.total_minutes_spent) > ~~(item.total_minutes)) {
|
||||
const overlogged_time = ~~(item.total_minutes_spent) - ~~(item.total_minutes);
|
||||
item.overlogged_time_string = formatDuration(moment.duration(overlogged_time, "minutes"));
|
||||
} else {
|
||||
item.overlogged_time_string = `0h 0m`;
|
||||
}
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
protected static getDateRangeClause(key: string, dateRange: string[]) {
|
||||
if (dateRange.length === 2) {
|
||||
const start = moment(dateRange[0]).format("YYYY-MM-DD");
|
||||
const end = moment(dateRange[1]).format("YYYY-MM-DD");
|
||||
let query = `AND task_work_log.created_at::DATE >= '${start}'::DATE AND task_work_log.created_at < '${end}'::DATE + INTERVAL '1 day'`;
|
||||
|
||||
if (start === end) {
|
||||
query = `AND task_work_log.created_at::DATE = '${start}'::DATE`;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
if (key === DATE_RANGES.YESTERDAY)
|
||||
return "AND task_work_log.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND task_work_log.created_at < CURRENT_DATE::DATE";
|
||||
if (key === DATE_RANGES.LAST_WEEK)
|
||||
return "AND task_work_log.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND task_work_log.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
if (key === DATE_RANGES.LAST_MONTH)
|
||||
return "AND task_work_log.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND task_work_log.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
if (key === DATE_RANGES.LAST_QUARTER)
|
||||
return "AND task_work_log.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND task_work_log.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
protected static formatEndDate(endDate: string) {
|
||||
const end = moment(endDate).format("YYYY-MM-DD");
|
||||
const fEndDate = moment(end);
|
||||
return fEndDate;
|
||||
}
|
||||
|
||||
protected static formatCurrentDate() {
|
||||
const current = moment().format("YYYY-MM-DD");
|
||||
const fCurrentDate = moment(current);
|
||||
return fCurrentDate;
|
||||
}
|
||||
|
||||
protected static getDaysLeft(endDate: string): number | null {
|
||||
if (!endDate) return null;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.diff(fCurrentDate, "days");
|
||||
}
|
||||
|
||||
protected static isOverdue(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isBefore(fCurrentDate);
|
||||
}
|
||||
|
||||
protected static isToday(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isSame(fCurrentDate);
|
||||
}
|
||||
|
||||
|
||||
public static async getProjectsByTeam(
|
||||
teamId: string,
|
||||
size: string | number | null,
|
||||
offset: string | number | null,
|
||||
searchQuery: string | null,
|
||||
sortField: string,
|
||||
sortOrder: string,
|
||||
statusClause: string,
|
||||
healthClause: string,
|
||||
categoryClause: string,
|
||||
archivedClause = "",
|
||||
teamFilterClause: string,
|
||||
projectManagersClause: string) {
|
||||
|
||||
const q = `SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT p.id,
|
||||
p.name,
|
||||
p.color_code,
|
||||
|
||||
p.health_id AS project_health,
|
||||
(SELECT color_code
|
||||
FROM sys_project_healths
|
||||
WHERE sys_project_healths.id = p.health_id) AS health_color,
|
||||
|
||||
pc.id AS category_id,
|
||||
pc.name AS category_name,
|
||||
pc.color_code AS category_color,
|
||||
|
||||
(SELECT name FROM clients WHERE id = p.client_id) AS client,
|
||||
|
||||
p.team_id,
|
||||
(SELECT name FROM teams WHERE id = p.team_id) AS team_name,
|
||||
|
||||
ps.id AS status_id,
|
||||
ps.name AS status_name,
|
||||
ps.color_code AS status_color,
|
||||
ps.icon AS status_icon,
|
||||
|
||||
start_date,
|
||||
end_date,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||
FROM (SELECT team_member_id AS id,
|
||||
(SELECT COALESCE(ROW_TO_JSON(pmi), '{}'::JSON)
|
||||
FROM (SELECT name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_member_id = pm.team_member_id
|
||||
AND tmiv.team_id = (SELECT team_id FROM projects WHERE id = p.id)) pmi) AS project_manager_info,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = pm.team_member_id
|
||||
AND email_invitations.team_id = (SELECT team_id
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_id = pm.team_member_id)) AS pending_invitation,
|
||||
(SELECT active FROM team_members WHERE id = pm.team_member_id)
|
||||
FROM project_members pm
|
||||
WHERE project_id =p.id
|
||||
AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')) pm) AS project_manager,
|
||||
|
||||
(SELECT COALESCE(SUM(total_minutes), 0)
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS estimated_time,
|
||||
|
||||
(SELECT SUM((SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
WHERE task_id = tasks.id))
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS actual_time,
|
||||
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT COUNT(ta.id) AS total,
|
||||
COUNT(CASE WHEN is_completed(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS done,
|
||||
COUNT(CASE WHEN is_doing(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS doing,
|
||||
COUNT(CASE WHEN is_todo(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS todo
|
||||
FROM tasks ta
|
||||
WHERE project_id = p.id) rec) AS tasks_stat,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT pu.content AS content,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM project_comment_mentions pcm
|
||||
LEFT JOIN users u ON pcm.informed_by = u.id
|
||||
WHERE pcm.comment_id = pu.id) rec) AS mentions,
|
||||
pu.updated_at
|
||||
FROM project_comments pu
|
||||
WHERE pu.project_id = p.id
|
||||
ORDER BY pu.updated_at DESC
|
||||
LIMIT 1) AS rec) AS update,
|
||||
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT attribute_type,
|
||||
log_type,
|
||||
-- new case,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
|
||||
ELSE (old_value) END) AS previous,
|
||||
|
||||
-- new case
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT name FROM users WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
|
||||
ELSE (new_value) END) AS current,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = tal.task_id)),
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
|
||||
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (CASE
|
||||
WHEN (new_value IS NOT NULL)
|
||||
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
|
||||
ELSE (next_string) END) AS name,
|
||||
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS assigned_user,
|
||||
(SELECT name FROM tasks WHERE tasks.id = tal.task_id)
|
||||
FROM task_activity_logs tal
|
||||
WHERE task_id IN (SELECT id FROM tasks t WHERE t.project_id = p.id)
|
||||
ORDER BY tal.created_at DESC
|
||||
LIMIT 1) rec) AS last_activity
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE ${teamFilterClause} ${searchQuery} ${healthClause} ${statusClause} ${categoryClause} ${projectManagersClause} ${archivedClause}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $2 OFFSET $3) t) AS projects
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE ${teamFilterClause} ${searchQuery} ${healthClause} ${statusClause} ${categoryClause} ${projectManagersClause} ${archivedClause};`;
|
||||
const result = await db.query(q, [teamId, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const project of data.projects) {
|
||||
if (project.project_manager) {
|
||||
project.project_manager.name = project.project_manager.project_manager_info.name;
|
||||
project.project_manager.avatar_url = project.project_manager.project_manager_info.avatar_url;
|
||||
project.project_manager.color_code = getColor(project.project_manager.name);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
public static convertSecondsToHoursAndMinutes(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
public static async exportProjects(teamId: string) {
|
||||
const q = `SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT p.id,
|
||||
p.name,
|
||||
(SELECT name
|
||||
FROM sys_project_healths
|
||||
WHERE sys_project_healths.id = p.health_id) AS project_health,
|
||||
pc.name AS category_name,
|
||||
(SELECT name FROM clients WHERE id = p.client_id) AS client,
|
||||
(SELECT name FROM teams WHERE id = p.team_id) AS team_name,
|
||||
ps.name AS status_name,
|
||||
start_date,
|
||||
end_date,
|
||||
(SELECT COALESCE(SUM(total_minutes), 0)
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS estimated_time,
|
||||
(SELECT SUM((SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
WHERE task_id = tasks.id))
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS actual_time,
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT COUNT(ta.id) AS total,
|
||||
COUNT(CASE WHEN is_completed(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS done,
|
||||
COUNT(CASE WHEN is_doing(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS doing,
|
||||
COUNT(CASE WHEN is_todo(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS todo
|
||||
FROM tasks ta
|
||||
WHERE project_id = p.id) rec) AS tasks_stat,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT pu.content AS content,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM project_comment_mentions pcm
|
||||
LEFT JOIN users u ON pcm.informed_by = u.id
|
||||
WHERE pcm.comment_id = pu.id) rec) AS mentions,
|
||||
pu.updated_at
|
||||
FROM project_comments pu
|
||||
WHERE pu.project_id = p.id
|
||||
ORDER BY pu.updated_at DESC
|
||||
LIMIT 1) AS rec) AS update,
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT attribute_type,
|
||||
log_type,
|
||||
-- new case,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
|
||||
ELSE (old_value) END) AS previous,
|
||||
|
||||
-- new case
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT name FROM users WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
|
||||
ELSE (new_value) END) AS current,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = tal.task_id)),
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
|
||||
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (CASE
|
||||
WHEN (new_value IS NOT NULL)
|
||||
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
|
||||
ELSE (next_string) END) AS name,
|
||||
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS assigned_user,
|
||||
(SELECT name FROM tasks WHERE tasks.id = tal.task_id)
|
||||
FROM task_activity_logs tal
|
||||
WHERE task_id IN (SELECT id FROM tasks t WHERE t.project_id = p.id)
|
||||
ORDER BY tal.created_at
|
||||
LIMIT 1) rec) AS last_activity
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE p.team_id = $1 ORDER BY p.name) t) AS projects
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE p.team_id = $1;`;
|
||||
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const project of data.projects) {
|
||||
project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
project.days_left = this.getDaysLeft(project.end_date);
|
||||
project.is_overdue = this.isOverdue(project.end_date);
|
||||
if (project.days_left && project.is_overdue) {
|
||||
project.days_left = project.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
project.is_today = this.isToday(project.end_date);
|
||||
project.estimated_time = this.convertMinutesToHoursAndMinutes(int(project.estimated_time));
|
||||
project.actual_time = this.convertSecondsToHoursAndMinutes(int(project.actual_time));
|
||||
project.tasks_stat = {
|
||||
todo: this.getPercentage(int(project.tasks_stat.todo), +project.tasks_stat.total),
|
||||
doing: this.getPercentage(int(project.tasks_stat.doing), +project.tasks_stat.total),
|
||||
done: this.getPercentage(int(project.tasks_stat.done), +project.tasks_stat.total)
|
||||
};
|
||||
if (project.update.length > 0) {
|
||||
const update = project.update[0];
|
||||
const placeHolders = update.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < update.mentions.length) {
|
||||
update.content = update.content.replace(placeHolder, ` @${update.mentions[index].user_name} `);
|
||||
}
|
||||
});
|
||||
}
|
||||
project.comment = update.content;
|
||||
}
|
||||
if (project.last_activity) {
|
||||
if (project.last_activity.attribute_type === "estimation") {
|
||||
project.last_activity.previous = formatDuration(moment.duration(project.last_activity.previous, "minutes"));
|
||||
project.last_activity.current = formatDuration(moment.duration(project.last_activity.current, "minutes"));
|
||||
}
|
||||
if (project.last_activity.assigned_user) project.last_activity.assigned_user.color_code = getColor(project.last_activity.assigned_user.name);
|
||||
project.last_activity.done_by.color_code = getColor(project.last_activity.done_by.name);
|
||||
project.last_activity.log_text = await formatLogText(project.last_activity);
|
||||
project.last_activity.attribute_type = project.last_activity.attribute_type?.replace(/_/g, " ");
|
||||
project.last_activity.last_activity_string = `${project.last_activity.done_by.name} ${project.last_activity.log_text} ${project.last_activity.attribute_type}`;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static async exportProjectsAll(teamId: string) {
|
||||
const q = `SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT p.id,
|
||||
p.name,
|
||||
(SELECT name
|
||||
FROM sys_project_healths
|
||||
WHERE sys_project_healths.id = p.health_id) AS project_health,
|
||||
pc.name AS category_name,
|
||||
(SELECT name FROM clients WHERE id = p.client_id) AS client,
|
||||
(SELECT name FROM teams WHERE id = p.team_id) AS team_name,
|
||||
ps.name AS status_name,
|
||||
start_date,
|
||||
end_date,
|
||||
(SELECT COALESCE(SUM(total_minutes), 0)
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS estimated_time,
|
||||
(SELECT SUM((SELECT COALESCE(SUM(time_spent), 0)
|
||||
FROM task_work_log
|
||||
WHERE task_id = tasks.id))
|
||||
FROM tasks
|
||||
WHERE project_id = p.id) AS actual_time,
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT COUNT(ta.id) AS total,
|
||||
COUNT(CASE WHEN is_completed(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS done,
|
||||
COUNT(CASE WHEN is_doing(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS doing,
|
||||
COUNT(CASE WHEN is_todo(ta.status_id, ta.project_id) IS TRUE THEN 1 END) AS todo
|
||||
FROM tasks ta
|
||||
WHERE project_id = p.id) rec) AS tasks_stat,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT pu.content AS content,
|
||||
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM project_comment_mentions pcm
|
||||
LEFT JOIN users u ON pcm.informed_by = u.id
|
||||
WHERE pcm.comment_id = pu.id) rec) AS mentions,
|
||||
pu.updated_at
|
||||
FROM project_comments pu
|
||||
WHERE pu.project_id = p.id
|
||||
ORDER BY pu.updated_at DESC
|
||||
LIMIT 1) AS rec) AS update,
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT attribute_type,
|
||||
log_type,
|
||||
-- new case,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = old_value::UUID)
|
||||
ELSE (old_value) END) AS previous,
|
||||
|
||||
-- new case
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT name FROM users WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT name FROM team_labels WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT name FROM task_statuses WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT name FROM task_priorities WHERE id = new_value::UUID)
|
||||
ELSE (new_value) END) AS current,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = tal.task_id)),
|
||||
(SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM users WHERE users.id = tal.user_id),
|
||||
(SELECT avatar_url FROM users WHERE users.id = tal.user_id)) rec) AS done_by,
|
||||
(CASE
|
||||
WHEN (attribute_type = 'assignee')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (CASE
|
||||
WHEN (new_value IS NOT NULL)
|
||||
THEN (SELECT name FROM users WHERE users.id = new_value::UUID)
|
||||
ELSE (next_string) END) AS name,
|
||||
(SELECT avatar_url FROM users WHERE users.id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS assigned_user,
|
||||
(SELECT name FROM tasks WHERE tasks.id = tal.task_id)
|
||||
FROM task_activity_logs tal
|
||||
WHERE task_id IN (SELECT id FROM tasks t WHERE t.project_id = p.id)
|
||||
ORDER BY tal.created_at
|
||||
LIMIT 1) rec) AS last_activity
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE in_organization(p.team_id, $1) ORDER BY p.name) t) AS projects
|
||||
FROM projects p
|
||||
LEFT JOIN project_categories pc ON pc.id = p.category_id
|
||||
LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id
|
||||
WHERE in_organization(p.team_id, $1);`;
|
||||
|
||||
const result = await db.query(q, [teamId]);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const project of data.projects) {
|
||||
project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
project.days_left = this.getDaysLeft(project.end_date);
|
||||
project.is_overdue = this.isOverdue(project.end_date);
|
||||
if (project.days_left && project.is_overdue) {
|
||||
project.days_left = project.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
project.is_today = this.isToday(project.end_date);
|
||||
project.estimated_time = this.convertMinutesToHoursAndMinutes(int(project.estimated_time));
|
||||
project.actual_time = this.convertSecondsToHoursAndMinutes(int(project.actual_time));
|
||||
project.tasks_stat = {
|
||||
todo: this.getPercentage(int(project.tasks_stat.todo), +project.tasks_stat.total),
|
||||
doing: this.getPercentage(int(project.tasks_stat.doing), +project.tasks_stat.total),
|
||||
done: this.getPercentage(int(project.tasks_stat.done), +project.tasks_stat.total)
|
||||
};
|
||||
if (project.update.length > 0) {
|
||||
const update = project.update[0];
|
||||
const placeHolders = update.content.match(/{\d+}/g);
|
||||
if (placeHolders) {
|
||||
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
|
||||
const index = parseInt(placeHolder.match(/\d+/)[0]);
|
||||
if (index >= 0 && index < update.mentions.length) {
|
||||
update.content = update.content.replace(placeHolder, ` @${update.mentions[index].user_name} `);
|
||||
}
|
||||
});
|
||||
}
|
||||
project.comment = update.content;
|
||||
}
|
||||
if (project.last_activity) {
|
||||
if (project.last_activity.attribute_type === "estimation") {
|
||||
project.last_activity.previous = formatDuration(moment.duration(project.last_activity.previous, "minutes"));
|
||||
project.last_activity.current = formatDuration(moment.duration(project.last_activity.current, "minutes"));
|
||||
}
|
||||
if (project.last_activity.assigned_user) project.last_activity.assigned_user.color_code = getColor(project.last_activity.assigned_user.name);
|
||||
project.last_activity.done_by.color_code = getColor(project.last_activity.done_by.name);
|
||||
project.last_activity.log_text = await formatLogText(project.last_activity);
|
||||
project.last_activity.attribute_type = project.last_activity.attribute_type?.replace(/_/g, " ");
|
||||
project.last_activity.last_activity_string = `${project.last_activity.done_by.name} ${project.last_activity.log_text} ${project.last_activity.attribute_type}`;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ReportingControllerBase from "./reporting-controller-base";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
|
||||
import db from "../../config/db";
|
||||
import {ServerResponse} from "../../models/server-response";
|
||||
|
||||
export default class ReportingInfoController extends ReportingControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getInfo(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT organization_name
|
||||
FROM organizations
|
||||
WHERE user_id = (SELECT user_id FROM teams WHERE id = $1);
|
||||
`;
|
||||
const result = await db.query(q, [this.getCurrentTeamId(req)]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import moment from "moment";
|
||||
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {getDatesForResourceAllocation, getWeekRange} from "../shared/tasks-controller-utils";
|
||||
import {getColor} from "../shared/utils";
|
||||
|
||||
|
||||
export default class ResourceallocationController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getProjectWiseResources(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {start, end} = req.query;
|
||||
|
||||
const dates = await getDatesForResourceAllocation(start as string, end as string);
|
||||
const months = await getWeekRange(dates);
|
||||
|
||||
const q = `SELECT get_project_wise_resources($1, $2, $3) as resources;`;
|
||||
const result = await db.query(q, [start, moment(dates.length && dates.at(-1)?.date).format("YYYY-MM-DD") || end, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const scheduleData = JSON.parse(data.resources);
|
||||
|
||||
for (const element of scheduleData) {
|
||||
for (const schedule of element.schedule) {
|
||||
const min = dates.findIndex((date) => moment(schedule.date_series).isSame(date.date, "days")) || 0;
|
||||
schedule.min = min + 1;
|
||||
}
|
||||
|
||||
for (const task of element.unassigned_tasks) {
|
||||
const min = dates.findIndex((date) => moment(task.date_series).isSame(date.date, "days")) || 0;
|
||||
task.min = min + 1;
|
||||
}
|
||||
|
||||
for (const member of element.project_members) {
|
||||
for (const task of member.tasks) {
|
||||
const min = dates.findIndex((date) => moment(task.date_series).isSame(date.date, "days")) || 0;
|
||||
task.min = min + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {projects: scheduleData, dates, months}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getUserWiseResources(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {start, end} = req.query;
|
||||
|
||||
const dates = await getDatesForResourceAllocation(start as string, end as string);
|
||||
const months = await getWeekRange(dates);
|
||||
|
||||
const q = `SELECT get_team_wise_resources($1, $2, $3) as resources;`;
|
||||
const result = await db.query(q, [start, moment(dates.length && dates.at(-1)?.date).format("YYYY-MM-DD") || end, req.user?.team_id || null]);
|
||||
const [data] = result.rows;
|
||||
|
||||
const scheduleData = JSON.parse(data.resources);
|
||||
|
||||
const obj = [];
|
||||
|
||||
for (const element of scheduleData) {
|
||||
element.color_code = getColor(element.name);
|
||||
for (const schedule of element.schedule) {
|
||||
const min = dates.findIndex((date) => moment(schedule.date_series).isSame(date.date, "days")) || 0;
|
||||
schedule.min = min + 1;
|
||||
}
|
||||
|
||||
for (const member of element.project_members) {
|
||||
for (const task of member.tasks) {
|
||||
const min = dates.findIndex((date) => moment(task.date_series).isSame(date.date, "days")) || 0;
|
||||
task.min = min + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {projects: scheduleData, dates, months}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import WorklenzControllerBase from ".././worklenz-controller-base";
|
||||
|
||||
export const GroupBy = {
|
||||
STATUS: "status",
|
||||
PRIORITY: "priority",
|
||||
LABELS: "labels",
|
||||
PHASE: "phase"
|
||||
};
|
||||
|
||||
export interface IScheduleTaskGroup {
|
||||
id?: string;
|
||||
name: string;
|
||||
color_code: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
tasks: any[];
|
||||
isExpand: boolean;
|
||||
}
|
||||
|
||||
export default class ScheduleTasksControllerBase extends WorklenzControllerBase {
|
||||
protected static calculateTaskCompleteRatio(totalCompleted: number, totalTasks: number) {
|
||||
if (totalCompleted === 0 && totalTasks === 0) return 0;
|
||||
const ratio = ((totalCompleted / totalTasks) * 100);
|
||||
return ratio == Infinity ? 100 : ratio.toFixed();
|
||||
}
|
||||
|
||||
public static updateTaskViewModel(task: any) {
|
||||
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
|
||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
||||
|
||||
if (typeof task.sub_tasks_count === "undefined") task.sub_tasks_count = "0";
|
||||
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.name_color = getColor(task.name);
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
task.show_sub_tasks = false;
|
||||
|
||||
if (task.phase_id) {
|
||||
task.phase_color = task.phase_name
|
||||
? getColor(task.phase_name) + TASK_PRIORITY_COLOR_ALPHA
|
||||
: null;
|
||||
}
|
||||
|
||||
if (Array.isArray(task.assignees)) {
|
||||
for (const assignee of task.assignees) {
|
||||
assignee.color_code = getColor(assignee.name);
|
||||
}
|
||||
}
|
||||
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
const totalCompleted = +task.completed_sub_tasks + +task.parent_task_completed;
|
||||
const totalTasks = +task.sub_tasks_count + 1; // +1 for parent
|
||||
task.complete_ratio = ScheduleTasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
|
||||
task.completed_count = totalCompleted;
|
||||
task.total_tasks_count = totalTasks;
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
868
worklenz-backend/src/controllers/schedule/schedule-controller.ts
Normal file
868
worklenz-backend/src/controllers/schedule/schedule-controller.ts
Normal file
@@ -0,0 +1,868 @@
|
||||
import db from "../../config/db";
|
||||
import { ParsedQs } from "qs";
|
||||
import HandleExceptions from "../../decorators/handle-exceptions";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
|
||||
import { getColor } from "../../shared/utils";
|
||||
import moment, { Moment } from "moment";
|
||||
import momentTime from "moment-timezone";
|
||||
import ScheduleTasksControllerBase, { GroupBy, IScheduleTaskGroup } from "./schedule-controller-base";
|
||||
|
||||
interface IDateUnions {
|
||||
date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
},
|
||||
logs_date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
},
|
||||
allocated_date_union: {
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
interface IDatesPair {
|
||||
start_date: string | null,
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export class IScheduleTaskListGroup implements IScheduleTaskGroup {
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
color_code: string;
|
||||
tasks: any[];
|
||||
isExpand: boolean;
|
||||
|
||||
constructor(group: any) {
|
||||
this.name = group.name;
|
||||
this.category_id = group.category_id || null;
|
||||
this.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
|
||||
this.tasks = [];
|
||||
this.isExpand = group.isExpand;
|
||||
}
|
||||
}
|
||||
|
||||
export default class ScheduleControllerV2 extends ScheduleTasksControllerBase {
|
||||
|
||||
private static GLOBAL_DATE_WIDTH = 35;
|
||||
private static GLOBAL_START_DATE = moment().format("YYYY-MM-DD");
|
||||
private static GLOBAL_END_DATE = moment().format("YYYY-MM-DD");
|
||||
|
||||
private static async getFirstLastDates(teamId: string, userId: string) {
|
||||
const q = `SELECT MIN(LEAST(allocated_from, allocated_to)) AS start_date,
|
||||
MAX(GREATEST(allocated_from, allocated_to)) AS end_date,
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(start_date) AS min_date, MAX(start_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
AND project_id NOT IN
|
||||
(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = $2)
|
||||
AND tasks.archived IS FALSE
|
||||
UNION
|
||||
SELECT MIN(end_date) AS min_date, MAX(end_date) AS max_date
|
||||
FROM tasks
|
||||
WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
AND project_id NOT IN
|
||||
(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = $2)
|
||||
AND tasks.archived IS FALSE) AS dates) rec) AS date_union,
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS start_date,
|
||||
MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS end_date
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
|
||||
WHERE t.project_id IN (SELECT id FROM projects WHERE team_id = $1)
|
||||
AND project_id NOT IN
|
||||
(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = $2)) rec) AS logs_date_union
|
||||
FROM project_member_allocations
|
||||
WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1)`;
|
||||
|
||||
const res = await db.query(q, [teamId, userId]);
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
private static validateEndDate(endDate: Moment): boolean {
|
||||
return endDate.isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static validateStartDate(startDate: Moment): boolean {
|
||||
return startDate.isBefore(moment(), "day");
|
||||
}
|
||||
|
||||
private static getScrollAmount(startDate: Moment) {
|
||||
const today = moment();
|
||||
const daysDifference = today.diff(startDate, "days");
|
||||
|
||||
return (this.GLOBAL_DATE_WIDTH * daysDifference);
|
||||
}
|
||||
|
||||
private static setAllocationIndicator(item: any) {
|
||||
if (moment(item.allocated_from).isValid() && moment(item.allocated_to).isValid()) {
|
||||
const daysFromStart = moment(item.allocated_from).diff(this.GLOBAL_START_DATE, "days");
|
||||
const indicatorOffset = daysFromStart * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
const daysDifference = moment(item.allocated_to).diff(item.allocated_from, "days");
|
||||
const indicatorWidth = (daysDifference + 1) * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
return { indicatorOffset, indicatorWidth };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
private static setIndicatorWithLogIndicator(item: any) {
|
||||
|
||||
const daysFromStart = moment(item.start_date).diff(this.GLOBAL_START_DATE, "days");
|
||||
const indicatorOffset = daysFromStart * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
const daysDifference = moment(item.end_date).diff(item.start_date, "days");
|
||||
const indicatorWidth = (daysDifference + 1) * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
let logIndicatorOffset = 0;
|
||||
let logIndicatorWidth = 0;
|
||||
|
||||
if (item.logs_date_union && item.logs_date_union.start_date && item.logs_date_union.end_date) {
|
||||
const daysFromIndicatorStart = moment(item.logs_date_union.start_date).diff(item.start_date, "days");
|
||||
logIndicatorOffset = daysFromIndicatorStart * this.GLOBAL_DATE_WIDTH;
|
||||
|
||||
const daysDifferenceFromIndicator = moment(item.logs_date_union.end_date).diff(item.logs_date_union.start_date, "days");
|
||||
logIndicatorWidth = (daysDifferenceFromIndicator + 1) * this.GLOBAL_DATE_WIDTH;
|
||||
}
|
||||
|
||||
const body = {
|
||||
indicatorOffset,
|
||||
indicatorWidth,
|
||||
logIndicatorOffset,
|
||||
logIndicatorWidth
|
||||
};
|
||||
|
||||
return body;
|
||||
|
||||
}
|
||||
|
||||
private static async setChartStartEnd(dateRange: IDatesPair, logsRange: IDatesPair, allocatedRange: IDatesPair, timeZone: string) {
|
||||
|
||||
const datesToCheck = [];
|
||||
|
||||
const body = {
|
||||
date_union: {
|
||||
start_date: dateRange.start_date ? momentTime.tz(dateRange.start_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
end_date: dateRange.end_date ? momentTime.tz(dateRange.end_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
},
|
||||
logs_date_union: {
|
||||
start_date: logsRange.start_date ? momentTime.tz(logsRange.start_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
end_date: logsRange.end_date ? momentTime.tz(logsRange.end_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
},
|
||||
allocated_date_union: {
|
||||
start_date: allocatedRange.start_date ? momentTime.tz(allocatedRange.start_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
end_date: allocatedRange.end_date ? momentTime.tz(allocatedRange.end_date, `${timeZone}`).format("YYYY-MM-DD") : null,
|
||||
}
|
||||
};
|
||||
|
||||
for (const dateKey in body) {
|
||||
if (body[dateKey as keyof IDateUnions] && body[dateKey as keyof IDateUnions].start_date) {
|
||||
datesToCheck.push(moment(body[dateKey as keyof IDateUnions].start_date));
|
||||
}
|
||||
if (body[dateKey as keyof IDateUnions] && body[dateKey as keyof IDateUnions].end_date) {
|
||||
datesToCheck.push(moment(body[dateKey as keyof IDateUnions].end_date));
|
||||
}
|
||||
}
|
||||
|
||||
const validDateToCheck = datesToCheck.filter((date) => date.isValid());
|
||||
|
||||
dateRange.start_date = moment.min(validDateToCheck).format("YYYY-MM-DD");
|
||||
dateRange.end_date = moment.max(validDateToCheck).format("YYYY-MM-DD");
|
||||
|
||||
return dateRange;
|
||||
}
|
||||
|
||||
private static async mainDateValidator(dateRange: any) {
|
||||
const today = new Date();
|
||||
let startDate = moment(today).clone().startOf("year");
|
||||
let endDate = moment(today).clone().endOf("year").add(1, "year");
|
||||
|
||||
if (dateRange.start_date && dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("year") : moment(today).clone().startOf("year");
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("year") : moment(dateRange.end_date).endOf("year");
|
||||
} else if (dateRange.start_date && !dateRange.end_date) {
|
||||
startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("year") : moment(today).clone().startOf("year");
|
||||
} else if (!dateRange.start_date && dateRange.end_date) {
|
||||
endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("year") : moment(dateRange.end_date).endOf("year");
|
||||
}
|
||||
return { startDate, endDate, today };
|
||||
}
|
||||
|
||||
private static async createDateColumns(xMonthsBeforeStart: Moment, xMonthsAfterEnd: Moment, today: Date) {
|
||||
const dateData = [];
|
||||
let days = -1;
|
||||
|
||||
const currentDate = xMonthsBeforeStart.clone();
|
||||
|
||||
while (currentDate.isBefore(xMonthsAfterEnd)) {
|
||||
const monthData = {
|
||||
month: currentDate.format("MMM YYYY"),
|
||||
weeks: [] as number[],
|
||||
days: [] as { day: number, name: string, isWeekend: boolean, isToday: boolean }[],
|
||||
};
|
||||
const daysInMonth = currentDate.daysInMonth();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayOfMonth = currentDate.date();
|
||||
const dayName = currentDate.format("ddd");
|
||||
const isWeekend = [0, 6].includes(currentDate.day());
|
||||
const isToday = moment(moment(today).format("YYYY-MM-DD")).isSame(moment(currentDate).format("YYYY-MM-DD"));
|
||||
monthData.days.push({ day: dayOfMonth, name: dayName, isWeekend, isToday });
|
||||
currentDate.add(1, "day");
|
||||
days++;
|
||||
}
|
||||
dateData.push(monthData);
|
||||
}
|
||||
|
||||
return { dateData, days };
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createDateRange(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const dates = await this.getFirstLastDates(req.params.id as string, req.user?.id as string);
|
||||
|
||||
const dateRange = dates.date_union;
|
||||
const logsRange = dates.logs_date_union;
|
||||
const allocatedRange = { start_date: dates.start_date, end_date: dates.end_date };
|
||||
|
||||
await this.setChartStartEnd(dateRange, logsRange, allocatedRange, req.query.timeZone as string);
|
||||
|
||||
const { startDate, endDate, today } = await this.mainDateValidator(dateRange);
|
||||
|
||||
const xMonthsBeforeStart = startDate.clone().subtract(3, "months");
|
||||
const xMonthsAfterEnd = endDate.clone().add(2, "year");
|
||||
|
||||
this.GLOBAL_START_DATE = moment(xMonthsBeforeStart).format("YYYY-MM-DD");
|
||||
this.GLOBAL_END_DATE = moment(xMonthsAfterEnd).format("YYYY-MM-DD");
|
||||
|
||||
const { dateData, days } = await this.createDateColumns(xMonthsBeforeStart, xMonthsAfterEnd, today);
|
||||
|
||||
const scrollBy = await this.getScrollAmount(xMonthsBeforeStart);
|
||||
|
||||
const result = {
|
||||
date_data: dateData,
|
||||
width: days + 1,
|
||||
scroll_by: scrollBy,
|
||||
chart_start: moment(this.GLOBAL_START_DATE).format("YYYY-MM-DD"),
|
||||
chart_end: moment(this.GLOBAL_END_DATE).format("YYYY-MM-DD")
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
|
||||
private static async getProjectsQuery(teamId: string, userId: string) {
|
||||
const q = `SELECT p.id,
|
||||
p.name,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = p.id
|
||||
UNION
|
||||
SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = p.id) AS dates) rec) AS date_union,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT pm.id AS project_member_id,
|
||||
tmiv.team_member_id,
|
||||
tmiv.user_id,
|
||||
name AS name,
|
||||
avatar_url,
|
||||
TRUE AS project_member,
|
||||
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = tmiv.team_member_id
|
||||
AND email_invitations.team_id = $1) AS pending_invitation,
|
||||
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT
|
||||
pma.id,
|
||||
pma.allocated_from,
|
||||
pma.allocated_to
|
||||
FROM project_member_allocations pma
|
||||
WHERE pma.team_member_id = tmiv.team_member_id
|
||||
AND pma.project_id = p.id) rec)
|
||||
AS allocations
|
||||
|
||||
FROM project_members pm
|
||||
INNER JOIN team_member_info_view tmiv
|
||||
ON pm.team_member_id = tmiv.team_member_id
|
||||
WHERE project_id = p.id
|
||||
ORDER BY NAME ASC) rec) AS members
|
||||
|
||||
FROM projects p
|
||||
WHERE team_id = $1
|
||||
AND p.id NOT IN
|
||||
(SELECT project_id FROM archived_projects WHERE user_id = $2)
|
||||
ORDER BY p.name`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId]);
|
||||
return result;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id as string;
|
||||
const teamId = req.params.id as string;
|
||||
const timeZone = req.query.timeZone as string;
|
||||
|
||||
const result = await this.getProjectsQuery(teamId, userId);
|
||||
|
||||
for (const project of result.rows) {
|
||||
|
||||
const { lowestDate, highestDate } = await this.setIndicatorDates(project, timeZone);
|
||||
|
||||
project.allocated_from = lowestDate ? moment(lowestDate).format("YYYY-MM-DD") : null;
|
||||
project.allocated_to = highestDate ? moment(highestDate).format("YYYY-MM-DD") : null;
|
||||
|
||||
const styles = this.setAllocationIndicator(project);
|
||||
|
||||
project.indicator_offset = styles?.indicatorOffset && project.members.length ? styles.indicatorOffset : 0;
|
||||
project.indicator_width = styles?.indicatorWidth && project.members.length ? styles.indicatorWidth : 0;
|
||||
|
||||
project.color_code = getColor(project.name);
|
||||
|
||||
for (const member of project.members) {
|
||||
|
||||
const mergedAllocation = await this.mergeAllocations(member.allocations);
|
||||
|
||||
member.allocations = mergedAllocation;
|
||||
|
||||
for (const allocation of member.allocations) {
|
||||
|
||||
allocation.allocated_from = allocation.allocated_from ? momentTime.tz(allocation.allocated_from, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
allocation.allocated_to = allocation.allocated_to ? momentTime.tz(allocation.allocated_to, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
|
||||
const styles = this.setAllocationIndicator(allocation);
|
||||
|
||||
allocation.indicator_offset = styles?.indicatorOffset ? styles?.indicatorOffset : 0;
|
||||
allocation.indicator_width = styles?.indicatorWidth ? styles?.indicatorWidth : 0;
|
||||
|
||||
}
|
||||
|
||||
member.color_code = getColor(member.name);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getSingleProjectIndicator(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const projectId = req.params.id as string;
|
||||
const teamMemberId = req.query.team_member_id as string;
|
||||
const timeZone = req.query.timeZone as string;
|
||||
const projectIndicatorRefresh = req.query.isProjectRefresh;
|
||||
|
||||
const q = `SELECT id,
|
||||
allocated_from,
|
||||
allocated_to,
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1
|
||||
UNION
|
||||
SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1) AS dates) rec) AS date_union
|
||||
FROM project_member_allocations
|
||||
WHERE team_member_id = $2
|
||||
AND project_id = $1`;
|
||||
|
||||
const result = await db.query(q, [projectId, teamMemberId]);
|
||||
|
||||
const body = {
|
||||
project_allocation: { start_date: null, end_date: null, indicator_offset: null, indicator_width: null },
|
||||
member_allocations: [{}]
|
||||
};
|
||||
|
||||
if (result.rows.length) {
|
||||
|
||||
const mergedAllocation = await this.mergeAllocations(result.rows);
|
||||
|
||||
result.rows = mergedAllocation;
|
||||
|
||||
for (const allocation of result.rows) {
|
||||
|
||||
allocation.allocated_from = allocation.allocated_from ? momentTime.tz(allocation.allocated_from, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
allocation.allocated_to = allocation.allocated_to ? momentTime.tz(allocation.allocated_to, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
|
||||
const styles = this.setAllocationIndicator(allocation);
|
||||
|
||||
allocation.indicator_offset = styles?.indicatorOffset ? styles?.indicatorOffset : 0;
|
||||
allocation.indicator_width = styles?.indicatorWidth ? styles?.indicatorWidth : 0;
|
||||
|
||||
}
|
||||
|
||||
body.member_allocations = result.rows;
|
||||
|
||||
}
|
||||
const qP = `SELECT id,
|
||||
allocated_from,
|
||||
allocated_to,
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1
|
||||
UNION
|
||||
SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1) AS dates) rec) AS date_union
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1`;
|
||||
|
||||
const resultP = await db.query(qP, [projectId]);
|
||||
|
||||
if (resultP.rows.length) {
|
||||
const project = resultP.rows[0];
|
||||
|
||||
const { lowestDate, highestDate } = await this.setIndicatorDates(project, timeZone);
|
||||
|
||||
if (lowestDate) project.start_date = lowestDate;
|
||||
if (highestDate) project.end_date = highestDate;
|
||||
|
||||
project.start_date = project.start_date ? moment(project.start_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
project.end_date = project.end_date ? moment(project.end_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
|
||||
const styles = this.setIndicatorWithLogIndicator(project);
|
||||
|
||||
project.indicator_offset = styles.indicatorOffset;
|
||||
project.indicator_width = styles.indicatorWidth;
|
||||
|
||||
body.project_allocation = project;
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getSingleProjectMember(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
const projectId = req.params.id as string;
|
||||
const teamMemberId = req.query.team_member_id as string;
|
||||
const timeZone = req.query.timeZone as string;
|
||||
const projectIndicatorRefresh = req.query.isProjectRefresh;
|
||||
|
||||
const q = `SELECT id,
|
||||
allocated_from,
|
||||
allocated_to,
|
||||
(SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date
|
||||
FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1
|
||||
UNION
|
||||
SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date
|
||||
FROM project_member_allocations
|
||||
WHERE project_id = $1) AS dates) rec) AS date_union
|
||||
FROM project_member_allocations
|
||||
WHERE team_member_id = $2
|
||||
AND project_id = $1`;
|
||||
|
||||
const result = await db.query(q, [projectId, teamMemberId]);
|
||||
|
||||
const body = {
|
||||
project_allocation: { start_date: null, end_date: null, indicator_offset: null, indicator_width: null },
|
||||
member_allocations: [{}]
|
||||
};
|
||||
|
||||
if (result.rows.length) {
|
||||
const project = result.rows[0];
|
||||
|
||||
const { lowestDate, highestDate } = await this.setIndicatorDates(project, timeZone);
|
||||
|
||||
if (lowestDate) project.start_date = lowestDate;
|
||||
if (highestDate) project.end_date = highestDate;
|
||||
|
||||
project.start_date = project.start_date ? moment(project.start_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
project.end_date = project.end_date ? moment(project.end_date).format("YYYY-MM-DD") : moment().format("YYYY-MM-DD");
|
||||
|
||||
const styles = this.setIndicatorWithLogIndicator(project);
|
||||
|
||||
project.indicator_offset = styles.indicatorOffset;
|
||||
project.indicator_width = styles.indicatorWidth;
|
||||
|
||||
const mergedAllocation = await this.mergeAllocations(result.rows);
|
||||
|
||||
result.rows = mergedAllocation;
|
||||
|
||||
for (const allocation of result.rows) {
|
||||
|
||||
allocation.allocated_from = allocation.allocated_from ? momentTime.tz(allocation.allocated_from, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
allocation.allocated_to = allocation.allocated_to ? momentTime.tz(allocation.allocated_to, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
|
||||
const styles = this.setAllocationIndicator(allocation);
|
||||
|
||||
allocation.indicator_offset = styles?.indicatorOffset ? styles?.indicatorOffset : 0;
|
||||
allocation.indicator_width = styles?.indicatorWidth ? styles?.indicatorWidth : 0;
|
||||
|
||||
}
|
||||
|
||||
body.member_allocations = result.rows;
|
||||
body.project_allocation = project;
|
||||
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, body));
|
||||
|
||||
}
|
||||
|
||||
private static async mergeAllocations(allocations: { id: string | null, allocated_from: string | null, allocated_to: string | null, indicator_offset: number, indicator_width: number }[]) {
|
||||
|
||||
if (!allocations.length) return [];
|
||||
|
||||
allocations.sort((a, b) => moment(a.allocated_from).diff(moment(b.allocated_from)));
|
||||
|
||||
const mergedRanges = [];
|
||||
|
||||
let currentRange = { ...allocations[0], ids: [allocations[0].id] };
|
||||
|
||||
for (let i = 1; i < allocations.length; i++) {
|
||||
const nextRange = allocations[i];
|
||||
|
||||
if (moment(currentRange.allocated_to).isSameOrAfter(nextRange.allocated_from)) {
|
||||
currentRange.allocated_to = moment.max(moment(currentRange.allocated_to), moment(nextRange.allocated_to)).toISOString();
|
||||
currentRange.ids.push(nextRange.id);
|
||||
} else {
|
||||
mergedRanges.push({ ...currentRange });
|
||||
currentRange = { ...nextRange, ids: [nextRange.id] };
|
||||
}
|
||||
}
|
||||
|
||||
mergedRanges.push({ ...currentRange });
|
||||
|
||||
return mergedRanges;
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static async setIndicatorDates(item: any, timeZone: string) {
|
||||
const datesToCheck = [];
|
||||
|
||||
item.date_union.start_date = item.date_union.start_date ? momentTime.tz(item.date_union.start_date, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
item.date_union.end_date = item.date_union.end_date ? momentTime.tz(item.date_union.end_date, `${timeZone}`).format("YYYY-MM-DD") : null;
|
||||
|
||||
for (const dateKey in item) {
|
||||
if (item[dateKey as keyof IDateUnions] && item[dateKey as keyof IDateUnions].start_date) {
|
||||
datesToCheck.push(moment(item[dateKey as keyof IDateUnions].start_date));
|
||||
}
|
||||
if (item[dateKey as keyof IDateUnions] && item[dateKey as keyof IDateUnions].end_date) {
|
||||
datesToCheck.push(moment(item[dateKey as keyof IDateUnions].end_date));
|
||||
}
|
||||
}
|
||||
|
||||
const validDateToCheck = datesToCheck.filter((date) => date.isValid());
|
||||
|
||||
const lowestDate = validDateToCheck.length ? moment.min(validDateToCheck).format("YYYY-MM-DD") : null;
|
||||
const highestDate = validDateToCheck.length ? moment.max(validDateToCheck).format("YYYY-MM-DD") : null;
|
||||
|
||||
|
||||
return {
|
||||
lowestDate,
|
||||
highestDate
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteMemberAllocations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const ids = req.body.toString() as string;
|
||||
const q = `DELETE FROM project_member_allocations WHERE id IN (${(ids || "").split(",").map(s => `'${s}'`).join(",")})`;
|
||||
await db.query(q);
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
|
||||
// ********************************************
|
||||
|
||||
private static isCountsOnly(query: ParsedQs) {
|
||||
return query.count === "true";
|
||||
}
|
||||
|
||||
public static isTasksOnlyReq(query: ParsedQs) {
|
||||
return ScheduleControllerV2.isCountsOnly(query) || query.parent_task;
|
||||
}
|
||||
|
||||
private static flatString(text: string) {
|
||||
return (text || "").split(" ").map(s => `'${s}'`).join(",");
|
||||
}
|
||||
|
||||
private static getFilterByMembersWhereClosure(text: string) {
|
||||
return text
|
||||
? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString(text)}))`
|
||||
: "";
|
||||
}
|
||||
|
||||
private static getStatusesQuery(filterBy: string) {
|
||||
return filterBy === "member"
|
||||
? `, (SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
|
||||
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code
|
||||
FROM task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
|
||||
WHERE project_id = t.project_id
|
||||
ORDER BY task_statuses.name) rec) AS statuses`
|
||||
: "";
|
||||
}
|
||||
|
||||
public static async getTaskCompleteRatio(taskId: string): Promise<{
|
||||
ratio: number;
|
||||
total_completed: number;
|
||||
total_tasks: number;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||
const [data] = result.rows;
|
||||
data.info.ratio = +data.info.ratio.toFixed();
|
||||
return data.info;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static getQuery(userId: string, options: ParsedQs) {
|
||||
const searchField = options.search ? "t.name" : "sort_order";
|
||||
const { searchQuery, sortField } = ScheduleControllerV2.toPaginationOptions(options, searchField);
|
||||
|
||||
const isSubTasks = !!options.parent_task;
|
||||
|
||||
const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order";
|
||||
const membersFilter = ScheduleControllerV2.getFilterByMembersWhereClosure(options.members as string);
|
||||
const statusesQuery = ScheduleControllerV2.getStatusesQuery(options.filterBy as string);
|
||||
|
||||
const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE";
|
||||
|
||||
|
||||
let subTasksFilter;
|
||||
|
||||
if (options.isSubtasksInclude === "true") {
|
||||
subTasksFilter = "";
|
||||
} else {
|
||||
subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL";
|
||||
}
|
||||
|
||||
const filters = [
|
||||
subTasksFilter,
|
||||
(isSubTasks ? "1 = 1" : archivedFilter),
|
||||
membersFilter
|
||||
].filter(i => !!i).join(" AND ");
|
||||
|
||||
return `
|
||||
SELECT id,
|
||||
name,
|
||||
t.project_id AS project_id,
|
||||
t.parent_task_id,
|
||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id)::INT AS sub_tasks_count,
|
||||
|
||||
t.status_id AS status,
|
||||
t.archived,
|
||||
t.sort_order,
|
||||
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||
(SELECT name
|
||||
FROM project_phases
|
||||
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name,
|
||||
|
||||
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category,
|
||||
|
||||
(CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END) AS parent_task_completed,
|
||||
(SELECT get_task_assignees(t.id)) AS assignees,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE tt.parent_task_id = t.id
|
||||
AND tt.is_done IS TRUE)::INT
|
||||
AS completed_sub_tasks,
|
||||
|
||||
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
total_minutes,
|
||||
start_date,
|
||||
end_date ${statusesQuery}
|
||||
FROM tasks t
|
||||
WHERE ${filters} ${searchQuery} AND project_id = $1
|
||||
ORDER BY end_date DESC NULLS LAST
|
||||
`;
|
||||
}
|
||||
|
||||
public static async getGroups(groupBy: string, projectId: string): Promise<IScheduleTaskGroup[]> {
|
||||
let q = "";
|
||||
let params: any[] = [];
|
||||
switch (groupBy) {
|
||||
case GroupBy.STATUS:
|
||||
q = `
|
||||
SELECT id,
|
||||
name,
|
||||
(SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id),
|
||||
category_id
|
||||
FROM task_statuses
|
||||
WHERE project_id = $1
|
||||
ORDER BY sort_order;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
case GroupBy.PRIORITY:
|
||||
q = `SELECT id, name, color_code
|
||||
FROM task_priorities
|
||||
ORDER BY value DESC;`;
|
||||
break;
|
||||
case GroupBy.LABELS:
|
||||
q = `
|
||||
SELECT id, name, color_code
|
||||
FROM team_labels
|
||||
WHERE team_id = $2
|
||||
AND EXISTS(SELECT 1
|
||||
FROM tasks
|
||||
WHERE project_id = $1
|
||||
AND EXISTS(SELECT 1 FROM task_labels WHERE task_id = tasks.id AND label_id = team_labels.id))
|
||||
ORDER BY name;
|
||||
`;
|
||||
break;
|
||||
case GroupBy.PHASE:
|
||||
q = `
|
||||
SELECT id, name, color_code, start_date, end_date
|
||||
FROM project_phases
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
`;
|
||||
params = [projectId];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await db.query(q, params);
|
||||
for (const row of result.rows) {
|
||||
row.isExpand = true;
|
||||
}
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
|
||||
const q = ScheduleControllerV2.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const tasks = [...result.rows];
|
||||
|
||||
const groups = await this.getGroups(groupBy, req.params.id);
|
||||
const map = groups.reduce((g: { [x: string]: IScheduleTaskGroup }, group) => {
|
||||
if (group.id)
|
||||
g[group.id] = new IScheduleTaskListGroup(group);
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
this.updateMapByGroup(tasks, groupBy, map);
|
||||
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
|
||||
if (groupBy === GroupBy.PHASE)
|
||||
group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA;
|
||||
|
||||
return {
|
||||
id: key,
|
||||
...group
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: IScheduleTaskGroup }) {
|
||||
let index = 0;
|
||||
const unmapped = [];
|
||||
for (const task of tasks) {
|
||||
task.index = index++;
|
||||
ScheduleControllerV2.updateTaskViewModel(task);
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
map[task.status]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PRIORITY) {
|
||||
map[task.priority]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PHASE && task.phase_id) {
|
||||
map[task.phase_id]?.tasks.push(task);
|
||||
} else {
|
||||
unmapped.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (unmapped.length) {
|
||||
map[UNMAPPED] = {
|
||||
name: UNMAPPED,
|
||||
category_id: null,
|
||||
color_code: "#f0f0f0",
|
||||
tasks: unmapped,
|
||||
isExpand: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const q = ScheduleControllerV2.getQuery(req.user?.id as string, req.query);
|
||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||
const result = await db.query(q, params);
|
||||
|
||||
let data: any[] = [];
|
||||
|
||||
// if true, we only return the record count
|
||||
if (this.isCountsOnly(req.query)) {
|
||||
[data] = result.rows;
|
||||
} else { // else we return a flat list of tasks
|
||||
data = [...result.rows];
|
||||
for (const task of data) {
|
||||
ScheduleControllerV2.updateTaskViewModel(task);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {createHmac} from "crypto";
|
||||
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class SharedprojectsController extends WorklenzControllerBase {
|
||||
private static getShareLink(hash: string) {
|
||||
return `https://${process.env.HOSTNAME}/share/${hash}`;
|
||||
}
|
||||
|
||||
private static createShareInfo(name?: string, createdAt?: string, hash?: string) {
|
||||
if (!name || !createdAt || !hash) return null;
|
||||
return {
|
||||
url: this.getShareLink(hash),
|
||||
created_by: name?.split(" ")[0],
|
||||
created_at: createdAt
|
||||
};
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO shared_projects (project_id, team_id, enabled_by, hash_value)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at;
|
||||
`;
|
||||
|
||||
const hash = createHmac("sha256", req.body.project_id).digest("hex");
|
||||
|
||||
const result = await db.query(q, [req.body.project_id, req.user?.team_id, req.user?.id, hash]);
|
||||
const [data] = result.rows;
|
||||
if (!data?.id)
|
||||
return res.status(400).send(new ServerResponse(true, null));
|
||||
return res.status(200).send(new ServerResponse(true, this.createShareInfo(req.user?.name?.split(" ")[0], data.createdAt, hash)));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT hash_value, created_at, (SELECT name FROM users WHERE id = enabled_by)
|
||||
FROM shared_projects
|
||||
WHERE project_id = $1
|
||||
AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, this.createShareInfo(data?.name, data?.created_at, data?.hash_value)));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `DELETE FROM shared_projects WHERE project_id = $1 AND team_id = $2;`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
119
worklenz-backend/src/controllers/sub-tasks-controller.ts
Normal file
119
worklenz-backend/src/controllers/sub-tasks-controller.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import moment from "moment";
|
||||
|
||||
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
import {getColor} from "../shared/utils";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class SubTasksController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getNames(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT name FROM tasks WHERE archived IS FALSE AND parent_task_id = $1;`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT t.id,
|
||||
t.name,
|
||||
t.description,
|
||||
t.project_id,
|
||||
t.parent_task_id,
|
||||
t.priority_id AS priority,
|
||||
tp.name AS priority_name,
|
||||
t.end_date,
|
||||
(ts.id) AS status,
|
||||
(ts.name) AS status_name,
|
||||
TRUE AS is_sub_task,
|
||||
(tsc.color_code) AS status_color,
|
||||
(SELECT name FROM projects WHERE id = t.project_id) AS project_name,
|
||||
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||
total_minutes,
|
||||
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
|
||||
(SELECT get_task_assignees(t.id)) AS assignees,
|
||||
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r)))
|
||||
FROM (SELECT task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = t.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code
|
||||
FROM task_statuses
|
||||
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
|
||||
WHERE project_id = t.project_id
|
||||
ORDER BY task_statuses.name) rec) AS statuses
|
||||
FROM tasks t
|
||||
INNER JOIN task_statuses ts ON ts.id = t.status_id
|
||||
INNER JOIN task_priorities tp ON tp.id = t.priority_id
|
||||
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
|
||||
WHERE parent_task_id = $1
|
||||
ORDER BY created_at;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
for (const task of result.rows) {
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || null;
|
||||
|
||||
task.time_spent = {hours: Math.floor(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
|
||||
task.time_spent_string = `${task.time_spent.hours}h ${task.time_spent.minutes}m`;
|
||||
task.total_time_string = `${Math.floor(task.total_minutes / 60)}h ${task.total_minutes % 60}m`;
|
||||
|
||||
task.assignees.map((a: any) => a.color_code = getColor(a.name));
|
||||
task.names = this.createTagList(task.assignees);
|
||||
task.labels = this.createTagList(task.labels, 2);
|
||||
|
||||
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
|
||||
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getSubTasksRoadMap(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const dates = req.body;
|
||||
const q = `
|
||||
SELECT tasks.id,
|
||||
tasks.name,
|
||||
tasks.start_date,
|
||||
tasks.end_date,
|
||||
tp.name AS priority,
|
||||
tasks.end_date,
|
||||
(SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||
FROM tasks
|
||||
INNER JOIN task_statuses ts ON ts.task_id = tasks.id
|
||||
INNER JOIN task_priorities tp ON tp.id = tasks.priority_id
|
||||
WHERE archived IS FALSE AND parent_task_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id]);
|
||||
|
||||
const maxInlineNames = 4;
|
||||
for (const task of result.rows) {
|
||||
task.assignees.map((a: any) => a.color_code = getColor(a.name));
|
||||
task.names = this.createTagList(task.assignees);
|
||||
|
||||
if (task?.assignees.length <= maxInlineNames) {
|
||||
const min: number = dates.findIndex((date: any) => moment(task.start_date).isSame(date.date, "days"));
|
||||
const max: number = dates.findIndex((date: any) => moment(task.end_date).isSame(date.date, "days"));
|
||||
task.min = min + 1;
|
||||
task.max = max > 0 ? max + 2 : max;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user