Initial commit: Angular frontend and Expressjs backend

This commit is contained in:
chamikaJ
2024-05-17 09:32:30 +05:30
parent eb0a0d77d6
commit 298ca6beeb
3548 changed files with 193558 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
node_modules
npm-debug.log
build
.scannerwork
coverage

View 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

View File

@@ -0,0 +1,55 @@
# Server
NODE_ENV=development
PORT=3000
SESSION_NAME=worklenz.sid
SESSION_SECRET="%7Z3Vn$d2sJdhbwXdZp2GhhDH6f5Z%_43bX#B-@fA=ntXVDsq$b=AuUm_NXZ7hTX?3EKpHwdAsnbUm-Sv!&bcX=xy2pfkwnTfeW$qhVu4_L&N-+s6gUY2V$%E=fDU^a?8ghjav?b!aGPfvAb7yKzSRR#4YsfgrHfMARQBmgF*_AzsHV@2=Jgz?wtERUHLDPh4R6t3VH7UydrgtQG24SaD?q_qFM_PX*_QDYs!8*An=aEpbbMJMVFj5t43_wWM$mM"
COOKIE_SECRET="CeD=C+_2LSSFF_qJ@cCz_$=KQv=ZrxU?DDL9$9%Yrd^yeeZ&h#QCSvX@u9^M!y%fnw^SU$-MQetU!eKLWR@n_pafJSU%*?nvr&qKgnUsj?5+Jnw$rFuPGWej-L&Cznk+rcKZ#8%Wcr$y%KAzCp597Z2Tnt?gkx=xsc%RNjcfkYeA=94JnLJxKur8p*HJ4?Q#5U%@BMhR4n67a-rZJEvnkFgxVcvaLdmEjXFe#26UkJV799MPP5wU7-&fpx4Vfkf="
# CORS
SOCKET_IO_CORS=http://localhost:4200
SERVER_CORS=*
# Database
DB_USER=DATABASE_USER_NAME_HERE
DB_PASSWORD=DATABASE_PASSWORD_HERE
DB_NAME=DATABASE_NAME_HERE
DB_HOST=DATABASE_HOST_HERE # localhost
DB_PORT=DATABASE_PORT_HERE
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" # "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
AWS_SECRET_ACCESS_KEY="AWS_SECRET_ACCESS_KEY_HERE" # "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# 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"

View 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
View 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
View File

@@ -0,0 +1,3 @@
[submodule "worklenz-email-templates"]
path = worklenz-email-templates
url = "URL_HERE"

2
worklenz-backend/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
fund=false # Don't print the trailing funding message

View 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"]

View 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", []);
};

View 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

View 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

View File

@@ -0,0 +1,6 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
};

View File

@@ -0,0 +1,3 @@
cd "PATH_HERE"
npm ci
npm run build

View 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");

View 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`);

View 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`);

View 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");

View 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();

View 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");

File diff suppressed because it is too large Load Diff

View 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

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
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;

View File

@@ -0,0 +1 @@
All database DDLs, DMLs and migrations relates to the application should be stored here as well.

View 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

View 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"
});

View 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"
}]
}
};

View 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: "C:\\Users\\dinindu\\AppData\\Local\\Temp\\jest",
// 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -0,0 +1 @@
901

View File

View 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
View 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;

View File

@@ -0,0 +1,6 @@
import dotenv from "dotenv";
import SegfaultHandler from "segfault-handler";
dotenv.config();
global.Promise = require("bluebird");
SegfaultHandler.registerHandler("crash.log");

View 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);

View 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,
};

View 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>>,
};

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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."));
}
}

View 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));
}
}

View 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!"));
}
}

View 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));
}
}

View 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));
}
}

View 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");
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View 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 { 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));
}
}

View 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));
}
}

View 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));
}
}

View File

@@ -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));
}
}

View 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 || []));
}
}

View File

@@ -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));
}
}

View 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));
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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 }));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
// }
}

View File

@@ -0,0 +1,744 @@
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 || [];
}
public static async updateExistPhaseColors() {
const q = `SELECT id, name FROM project_phases`;
const phases = await db.query(q);
phases.rows.forEach((phase) => {
phase.color_code = getColor(phase.name);
});
const body = {
phases: phases.rows
};
const q2 = `SELECT update_existing_phase_colors($1)`;
await db.query(q2, [JSON.stringify(body)]);
}
public static async updateExistSortOrder() {
const q = `SELECT id, project_id FROM project_phases ORDER BY name`;
const phases = await db.query(q);
const sortNumbers: any = {};
phases.rows.forEach(phase => {
const projectId = phase.project_id;
if (!sortNumbers[projectId]) {
sortNumbers[projectId] = 0;
}
phase.sort_number = sortNumbers[projectId]++;
});
const body = {
phases: phases.rows
};
const q2 = `SELECT update_existing_phase_sort_order($1)`;
await db.query(q2, [JSON.stringify(body)]);
// return phases;
}
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -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)
];
}
}

View File

@@ -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));
}
}

View File

@@ -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();
});
}
}

View File

@@ -0,0 +1,4 @@
import ReportingControllerBase from "../reporting-controller-base";
export default class ReportingProjectsBase extends ReportingControllerBase {
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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}));
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,945 @@
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");
// Migrate data
@HandleExceptions()
public static async migrate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const getDataq = `SELECT p.id,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
FROM (SELECT tmiv.team_member_id,
tmiv.user_id,
LEAST(
(SELECT MIN(LEAST(start_date, end_date)) AS start_date
FROM tasks
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
WHERE archived IS FALSE
AND project_id = p.id
AND ta.team_member_id = tmiv.team_member_id),
(SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_start_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 = p.id
AND twl.user_id = tmiv.user_id)
) AS lowest_date,
GREATEST(
(SELECT MAX(GREATEST(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 = p.id
AND ta.team_member_id = tmiv.team_member_id),
(SELECT MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_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 = p.id
AND twl.user_id = tmiv.user_id)
) AS greatest_date
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) rec) AS members
FROM projects p
WHERE team_id IS NOT NULL
AND p.id NOT IN (SELECT project_id FROM archived_projects)`;
const projectMembersResults = await db.query(getDataq);
const projectMemberData = projectMembersResults.rows;
const arrayToInsert = [];
for (const data of projectMemberData) {
if (data.members.length) {
for (const member of data.members) {
const body = {
project_id: data.id,
team_member_id: member.team_member_id,
allocated_from: member.lowest_date ? member.lowest_date : null,
allocated_to: member.greatest_date ? member.greatest_date : null
};
if (body.allocated_from && body.allocated_to) arrayToInsert.push(body);
}
}
}
const insertArray = JSON.stringify(arrayToInsert);
const insertFunctionCall = `SELECT migrate_member_allocations($1)`;
await db.query(insertFunctionCall, [insertArray]);
return res.status(200).send(new ServerResponse(true, ""));
}
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));
}
}

View File

@@ -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));
}
}

View 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));
}
}

View File

@@ -0,0 +1,265 @@
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 { NotificationsService } from "../services/notifications/notifications.service";
import { log_error } from "../shared/utils";
import { HTML_TAG_REGEXP } from "../shared/constants";
import { getBaseUrl } from "../cron_jobs/helpers";
import { ICommentEmailNotification } from "../interfaces/comment-email-notification";
import { sendTaskComment } from "../shared/email-notifications";
interface ITaskAssignee {
team_member_id: string;
project_member_id: string;
name: string;
email_notifications_enabled: string;
avatar_url: string;
user_id: string;
email: string;
socket_id: string;
team_id: string;
user_name: string;
}
interface IMailConfig {
message: string;
receiverEmail: string;
receiverName: string;
content: string;
commentId: string;
projectId: string;
taskId: string;
teamName: string;
projectName: string;
taskName: string;
}
interface IMention {
team_member_id: string;
name: string;
}
async function getAssignees(taskId: string): Promise<Array<ITaskAssignee>> {
const result1 = await db.query("SELECT get_task_assignees($1) AS assignees;", [taskId]);
const [d] = result1.rows;
return d.assignees || [];
}
export default class TaskCommentsController extends WorklenzControllerBase {
private static replaceContent(messageContent: string, mentions: IMention[]) {
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 getUserDataByTeamMemberId(senderUserId: string, teamMemberId: string, projectId: string) {
const q = `
SELECT id,
socket_id,
users.name AS user_name,
(SELECT email_notifications_enabled
FROM notification_settings
WHERE notification_settings.team_id = (SELECT team_id FROM team_members WHERE id = $2)
AND notification_settings.user_id = users.id),
(SELECT name FROM teams WHERE id = (SELECT team_id FROM team_members WHERE id = $2)) AS team,
(SELECT name FROM projects WHERE id = $3) AS project,
(SELECT color_code FROM projects WHERE id = $3) AS project_color
FROM users
WHERE id != $1
AND id IN (SELECT user_id FROM team_members WHERE id = $2);
`;
const result = await db.query(q, [senderUserId, teamMemberId, projectId]);
const [data] = result.rows;
return data;
}
private static async updateComment(commentId: string, messageId: string) {
if (!commentId || messageId) return;
try {
await db.query("UPDATE task_comments SET ses_message_id = $2 WHERE id = $1;", [commentId, messageId]);
} catch (e) {
log_error(e);
}
}
@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;
const {mentions} = req.body;
let commentContent = req.body.content;
if (mentions.length > 0) {
commentContent = await this.replaceContent(commentContent, mentions);
}
req.body.content = commentContent;
const q = `SELECT create_task_comment($1) AS comment;`;
const result = await db.query(q, [JSON.stringify(req.body)]);
const [data] = result.rows;
const response = data.comment;
const mentionMessage = `<b>${req.user?.name}</b> has mentioned you in a comment on <b>${response.task_name}</b> (${response.team_name})`;
// const mentions = [...new Set(req.body.mentions || [])] as string[]; // remove duplicates
const assignees = await getAssignees(req.body.task_id);
const commentMessage = `<b>${req.user?.name}</b> added a comment on <b>${response.task_name}</b> (${response.team_name})`;
for (const member of assignees || []) {
if (member.user_id && member.user_id === req.user?.id) continue;
void NotificationsService.createNotification({
userId: member.user_id,
teamId: req.user?.team_id as string,
socketId: member.socket_id,
message: commentMessage,
taskId: req.body.task_id,
projectId: response.project_id
});
if (member.email_notifications_enabled)
await this.sendMail({
message: commentMessage,
receiverEmail: member.email,
receiverName: member.name,
content: req.body.content,
commentId: response.id,
projectId: response.project_id,
taskId: req.body.task_id,
teamName: response.team_name,
projectName: response.project_name,
taskName: response.task_name
});
}
const senderUserId = req.user?.id as string;
for (const mention of mentions) {
if (mention) {
const member = await this.getUserDataByTeamMemberId(senderUserId, mention.team_member_id, response.project_id);
if (member) {
NotificationsService.sendNotification({
team: member.team,
receiver_socket_id: member.socket_id,
message: mentionMessage,
task_id: req.body.task_id,
project_id: response.project_id,
project: member.project,
project_color: member.project_color,
team_id: req.user?.team_id as string
});
if (member.email_notifications_enabled)
await this.sendMail({
message: mentionMessage,
receiverEmail: member.email,
receiverName: member.user_name,
content: req.body.content,
commentId: response.id,
projectId: response.project_id,
taskId: req.body.task_id,
teamName: response.team_name,
projectName: response.project_name,
taskName: response.task_name
});
}
}
}
return res.status(200).send(new ServerResponse(true, data.comment));
}
private static async sendMail(config: IMailConfig) {
const subject = config.message.replace(HTML_TAG_REGEXP, "");
const taskUrl = `${getBaseUrl()}/worklenz/projects/${config.projectId}?tab=tasks-list&task=${config.taskId}&focus=comments`;
const settingsUrl = `${getBaseUrl()}/worklenz/settings/notifications`;
const data: ICommentEmailNotification = {
greeting: `Hi ${config.receiverName}`,
summary: subject,
team: config.teamName,
project_name: config.projectName,
comment: config.content,
task: config.taskName,
settings_url: settingsUrl,
task_url: taskUrl,
};
const messageId = await sendTaskComment(config.receiverEmail, data);
if (messageId) {
void TaskCommentsController.updateComment(config.commentId, messageId);
}
}
@HandleExceptions()
public static async getByTaskId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT task_comments.id,
tc.text_content AS content,
task_comments.user_id,
task_comments.team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS member_name,
u.avatar_url,
task_comments.created_at,
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
FROM (SELECT tmiv.name AS user_name,
tmiv.email AS user_email
FROM task_comment_mentions tcm
LEFT JOIN team_member_info_view tmiv ON tcm.informed_by = tmiv.team_member_id
WHERE tcm.comment_id = task_comments.id) rec) AS mentions
FROM task_comments
INNER JOIN task_comment_contents tc ON task_comments.id = tc.comment_id
INNER JOIN team_members tm ON task_comments.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE task_comments.task_id = $1
ORDER BY task_comments.created_at DESC;
`;
const result = await db.query(q, [req.params.id]); // task id
for (const comment of result.rows) {
comment.content = await comment.content.replace(/\n/g, "</br>");
const {mentions} = comment;
if (mentions.length > 0) {
const placeHolders = comment.content.match(/{\d+}/g);
if (placeHolders) {
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>`);
}
});
}
}
}
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 task_comments
WHERE id = $1
AND task_id = $2
AND user_id = $3;`;
const result = await db.query(q, [req.params.id, req.params.taskId, req.user?.id || null]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -0,0 +1,39 @@
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 TaskListColumnsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getProjectTaskListColumns(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT name,
key,
index,
pinned,
(SELECT phase_label FROM projects WHERE id = $1) AS phase_label
FROM project_task_list_cols
WHERE project_id = $1
ORDER BY index;
`;
const result = await db.query(q, [req.params.id]);
const phase = result.rows.find(phase => phase.key === "PHASE");
if (phase)
phase.name = phase.phase_label;
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async toggleColumn(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `UPDATE project_task_list_cols
SET pinned = $3
WHERE project_id = $1
AND key = $2;`;
const result = await db.query(q, [req.params.id, req.body.key, !!req.body.pinned]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -0,0 +1,119 @@
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 {TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
export default class TaskPhasesController 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 project_phases (name, color_code, project_id, sort_index)
VALUES (
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
$1,
$2,
(SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1)
RETURNING id, name, color_code, sort_index;
`;
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 = getColor(data.name) + 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 task_phase WHERE phase_id = project_phases.id) AS usage
FROM project_phases
WHERE project_id = $1
ORDER BY sort_index 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()
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
UPDATE project_phases
SET name = $3
WHERE id = $1
AND project_id = $2
RETURNING id, name, color_code;
`;
const result = await db.query(q, [req.params.id, req.query.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 project_phases SET color_code = $3 WHERE id = $1 AND project_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 updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
UPDATE projects
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 updateSortOrder (req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const body = {
phases: req.body.phases.reverse(),
project_id: req.body.project_id
};
const q = `SELECT handle_phase_sort_order($1);`;
const result = await db.query(q, [JSON.stringify(body)]);
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 project_phases
WHERE id = $1
AND project_id = $2
`;
const result = await db.query(q, [req.params.id, req.query.id]);
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